Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b62b5769 | ||
|
|
12c2223c15 | ||
|
|
db0249eb74 | ||
|
|
29b1beabe0 | ||
|
|
f605fbfd85 | ||
|
|
be6fed4986 | ||
|
|
44e2ced266 | ||
|
|
e54e140046 | ||
|
|
e1427c062c | ||
|
|
fdd6b8a51f | ||
|
|
f1cf20f439 | ||
|
|
5f8a8607a9 | ||
|
|
c8cf5c195f | ||
|
|
3805be7b42 | ||
|
|
f744def6ed | ||
|
|
165edf1a1e |
@@ -1,6 +1,7 @@
|
||||
# Java / Gradle
|
||||
*.class
|
||||
*.jar
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
*.war
|
||||
*.ear
|
||||
*.log
|
||||
@@ -46,3 +47,9 @@ dkim-keys/
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# local data of the dev server
|
||||
backend/attachments/
|
||||
backend/dkim-keys/
|
||||
attachments/
|
||||
dkim-keys/
|
||||
|
||||
@@ -1,440 +1,109 @@
|
||||
# Mail Server
|
||||
# Reactive Mail Server
|
||||
|
||||
Прототип почтового сервера на **Spring Boot + Angular** с REST API для управления письмами.
|
||||
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
|
||||
|
||||
## Архитектура
|
||||
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend.
|
||||
|
||||
## Как устроено
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Angular Frontend │
|
||||
│ (веб-почта: список, чтение, написание, контакты) │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ REST API (JSON)
|
||||
┌──────────────────────▼──────────────────────────────┐
|
||||
│ Spring Boot Backend │
|
||||
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Users │ │ Messages │ │ Contacts │ │
|
||||
│ └────────────┘ └────────────┘ └──────────────┘ │
|
||||
│ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ Folders │ │Attachments │ │
|
||||
│ └────────────┘ └────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────▼────────┐ │
|
||||
│ │ JavaMailSender │ │
|
||||
│ │ (SMTP отправка)│ │
|
||||
│ └─────────────────┘ │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ JPA
|
||||
┌──────────────────────▼──────────────────────────────┐
|
||||
│ PostgreSQL 17 │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
SMTP-клиент / чужой сервер ──► SmtpServer (Netty, порт 3025) ──┐
|
||||
├─► MailRouter ─┬─► LocalDelivery → свой ящик (PostgreSQL)
|
||||
Веб-почта (Angular) ──► REST /api ──► SubmissionService ──────┘ └─► OutboundQueue → DeliveryWorker → SMTP получателя
|
||||
```
|
||||
|
||||
## Запуск
|
||||
- **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
|
||||
- **Не открытый релей.**
|
||||
- Письмо на свой домен принимается от кого угодно.
|
||||
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
|
||||
- Вошедший пользователь пишет только от своего адреса, иначе `553`.
|
||||
- **Маршрут** (`MailRouter`) один для SMTP и веб-почты:
|
||||
- есть такой ящик в нашем домене → в него;
|
||||
- задан `MAIL_CATCH_ALL` → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
|
||||
- наш домен без такого ящика → «нет такого пользователя»;
|
||||
- чужой домен → очередь.
|
||||
- **Отправка наружу** (`OUTBOUND_MODE`):
|
||||
- `off` — ничего не уходит;
|
||||
- `mx` — напрямую на MX получателя, порт 25, STARTTLS по возможности;
|
||||
- `smarthost` — через провайдера (Resend, SES) с логином и проверкой TLS.
|
||||
- **Очередь** — таблица `outbound_queue`. Временная ошибка — повтор через 1 мин, 5 мин, 15 мин, 1 ч, 3 ч, 6 ч, 12 ч. Постоянная ошибка или исчерпанные повторы — уведомление отправителю во входящие.
|
||||
- **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`.
|
||||
- **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах.
|
||||
|
||||
### 1. Docker Compose (рекомендуется)
|
||||
## Веб-почта
|
||||
|
||||
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` (имя файла = имя переменной).
|
||||
|
||||
| Переменная | Что | По умолчанию |
|
||||
|---|---|---|
|
||||
| `DB_PASSWORD` | пароль PostgreSQL | **обязательно** |
|
||||
| `JWT_SECRET` | ключ токенов входа, ≥ 32 байт | **обязательно** |
|
||||
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` |
|
||||
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
||||
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
||||
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
||||
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
||||
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
||||
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
||||
| `SMTP_PORT` | порт SMTP | `3025` |
|
||||
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
|
||||
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
|
||||
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
|
||||
| `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
|
||||
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
|
||||
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — |
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
cd mail-server
|
||||
docker compose up --build
|
||||
docker compose up -d # PostgreSQL на 127.0.0.1:5433
|
||||
./gradlew :backend:bootRun --args='--spring.profiles.active=dev' # API :8080, SMTP :3025, admin@mailserver.local / admin123
|
||||
cd frontend && npm install && npx ng serve # http://localhost:4200, /api → :8080
|
||||
./gradlew :backend:test # тесты (база не нужна)
|
||||
```
|
||||
|
||||
Сервисы:
|
||||
- **REST API**: http://localhost:8080
|
||||
- **SMTP (приём)**: localhost:3025
|
||||
- **IMAP (доступ к письмам)**: localhost:143
|
||||
- **Mailpit UI**: http://localhost:8025 (SMTP на порту 1025)
|
||||
- **PostgreSQL**: localhost:5432
|
||||
Профиль `dev` — только для своего компьютера: известные пароли, открытая регистрация, `POST /api/simulate/receive` (письмо в ящик без SMTP).
|
||||
|
||||
### 2. Локально
|
||||
## Деплой на Jambotron (тест)
|
||||
|
||||
Требования:
|
||||
- Java 21
|
||||
- PostgreSQL 17
|
||||
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
|
||||
|
||||
```bash
|
||||
# Запустите PostgreSQL
|
||||
docker run -d --name mail-db -p 5432:5432 \
|
||||
-e POSTGRES_DB=mailserver \
|
||||
-e POSTGRES_USER=mail \
|
||||
-e POSTGRES_PASSWORD=secret \
|
||||
postgres:17
|
||||
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
|
||||
- Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли — в файле `/app/data/initial-passwords.txt` контейнера `mail` (том `data`, права 600): `docker exec <контейнер mail> cat /app/data/initial-passwords.txt`. Прочитать и сменить пароль в настройках.
|
||||
- Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`.
|
||||
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
|
||||
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
|
||||
|
||||
# Запустите Mailpit для тестовой отправки
|
||||
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
|
||||
### Ловушка почты для dev и staging (75.4)
|
||||
|
||||
# Соберите и запустите бэкенд
|
||||
cd backend
|
||||
./gradlew bootRun
|
||||
```
|
||||
В `docker-compose.jambotron.yml` включена ловушка: `MAIL_CATCH_ALL=trap@mail.test`. Письмо на любой адрес, у которого здесь нет своего ящика, — `user@gmail.com`, `nobody@mail.test` — ложится в ящик `trap@mail.test`; письма между ящиками стенда идут как обычно. Наружу не уходит ничего (`OUTBOUND_MODE=off`), порт 25 и репутация IP не нужны. Выключить — `MAIL_CATCH_ALL=` (пусто).
|
||||
|
||||
## API
|
||||
Чтобы staging Jambotron слал письма сюда вместо Resend, его `spring.mail` смотрит на SMTP этого сервера: хост — адрес, по которому staging видит SMTP 3025, без логина и без TLS. Как: в Jambotron, карточка «Домены» проекта → «Внутренние TCP-порты» → сервис `mail`, порт `3025` (Jambotron PR #781). Адрес `vmIp:vmPort` доступен только в сети WireGuard Jambotron, из интернета — нет.
|
||||
|
||||
### Тестовый пользователь
|
||||
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
|
||||
|
||||
При первом запуске приложения автоматически создаётся тестовый пользователь:
|
||||
## Не сделано
|
||||
|
||||
- **Email:** `admin@mailserver.local`
|
||||
- **Пароль:** `admin123`
|
||||
|
||||
### Регистрация
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"secret123","displayName":"Test User"}'
|
||||
```
|
||||
|
||||
### Логин
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"secret123"}'
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGci...",
|
||||
"email": "test@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Все защищённые эндпоинты требуют заголовок `Authorization: Bearer <token>`.
|
||||
|
||||
### Получить папки
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/folders \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Получить письма из папки
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/messages?folderId=1&page=0&size=20" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Отправить письмо
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"to":"recipient@example.com","subject":"Hello","body":"Test message"}'
|
||||
```
|
||||
|
||||
### Симулировать входящее письмо (для тестов)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/simulate/receive \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"senderEmail":"sender@example.com",
|
||||
"senderName":"Sender",
|
||||
"recipientEmail":"test@example.com",
|
||||
"subject":"Incoming test",
|
||||
"body":"This is a test message"
|
||||
}'
|
||||
```
|
||||
|
||||
### Отметить как прочитанное
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:8080/api/messages/1/read \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Удалить письмо
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/messages/1 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Загрузить вложение к письму
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/messages/1/attachments \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-F "file=@/path/to/file.pdf"
|
||||
```
|
||||
|
||||
### Получить список вложений письма
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/messages/1/attachments \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Скачать вложение
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/attachments/1/download \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-o file.pdf
|
||||
```
|
||||
|
||||
### Удалить вложение
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8080/api/attachments/1 \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
### Поиск по письмам
|
||||
|
||||
Поиск поддерживает два режима: простой поиск по ключевому слову и расширенный поиск с отдельными фильтрами.
|
||||
|
||||
**Простой поиск** — ищет по ключевому слову в теме, теле, отправителе и получателе:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8080/api/messages/search?folderId=1&keyword=hello" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
**Расширенный поиск** — используйте отдельные параметры для точной фильтрации:
|
||||
|
||||
```bash
|
||||
# Поиск по теме
|
||||
curl "http://localhost:8080/api/messages/search?folderId=1&subject=report" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Поиск по тексту письма
|
||||
curl "http://localhost:8080/api/messages/search?folderId=1&body=invoice" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Поиск по отправителю
|
||||
curl "http://localhost:8080/api/messages/search?folderId=1&sender=company.com" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Комбинированный поиск (все фильтры применяются одновременно)
|
||||
curl "http://localhost:8080/api/messages/search?folderId=1&subject=report&sender=company.com" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
Все параметры поиска (`keyword`, `subject`, `body`, `sender`, `recipient`) необязательны. Если ни один параметр не указан, возвращаются все письма папки.
|
||||
|
||||
### Отправить письмо через SMTP-сервер
|
||||
|
||||
Встроенный SMTP-сервер слушает порт **3025**. Любое письмо, отправленное на этот порт, будет сохранено в базе данных, если получатель зарегистрирован в системе.
|
||||
|
||||
```bash
|
||||
# Отправить письмо через telnet
|
||||
telnet localhost 3025
|
||||
```
|
||||
|
||||
Или с помощью curl:
|
||||
|
||||
```bash
|
||||
curl --url 'smtp://localhost:3025' \
|
||||
--mail-from 'sender@example.com' \
|
||||
--mail-rcpt 'test@example.com' \
|
||||
-T <(echo -e 'From: sender@example.com\nTo: test@example.com\nSubject: SMTP Test\n\nHello from SMTP!')
|
||||
```
|
||||
|
||||
Письмо появится во входящих (INBOX) пользователя `test@example.com`.
|
||||
|
||||
### Управление чёрным списком
|
||||
|
||||
Добавьте отправителя в чёрный список — письма от него будут автоматически помечаться как спам.
|
||||
|
||||
**Получить список заблокированных отправителей:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/spam/blacklist \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
**Добавить отправителя в чёрный список:**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/spam/blacklist \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-d '{"senderEmail":"spammer@example.com","reason":"Спам"}'
|
||||
```
|
||||
|
||||
**Удалить отправителя из чёрного списка:**
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8080/api/spam/blacklist?senderEmail=spammer@example.com" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
Письма от заблокированных отправителей автоматически получают флаг `spam: true` и могут быть отфильтрованы на стороне клиента.
|
||||
|
||||
### Подключение через IMAP-сервер
|
||||
|
||||
Встроенный IMAP-сервер слушает порт **143**. Вы можете подключиться к нему из любого почтового клиента (Thunderbird, Outlook, Apple Mail) или через telnet для тестирования.
|
||||
|
||||
```bash
|
||||
# Подключение через telnet
|
||||
telnet localhost 143
|
||||
```
|
||||
|
||||
Пример сессии IMAP:
|
||||
|
||||
```
|
||||
a LOGIN test@example.com secret123
|
||||
a OK LOGIN completed
|
||||
|
||||
a LIST "" "*"
|
||||
* LIST (\HasNoChildren) "." "INBOX"
|
||||
* LIST (\HasNoChildren) "." "Sent"
|
||||
a OK LIST completed
|
||||
|
||||
a SELECT "INBOX"
|
||||
* 5 EXISTS
|
||||
* 0 RECENT
|
||||
* FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
|
||||
a OK [READ-WRITE] SELECT completed
|
||||
|
||||
a FETCH 1 (BODY[])
|
||||
* 1 FETCH (UID 1 FLAGS (\Seen) BODY[] {123
|
||||
Hello, this is a test message.
|
||||
))
|
||||
a OK FETCH completed
|
||||
|
||||
a SEARCH ALL
|
||||
* SEARCH 1 2 3 4 5
|
||||
a OK SEARCH completed
|
||||
|
||||
a LOGOUT
|
||||
* BYE IMAP4rev1 server terminating
|
||||
a OK LOGOUT completed
|
||||
```
|
||||
|
||||
Поддерживаемые IMAP-команды:
|
||||
- **LOGIN** — аутентификация по email и паролю
|
||||
- **SELECT / EXAMINE** — выбор папки для работы
|
||||
- **FETCH** — получение заголовков и тела писем
|
||||
- **SEARCH** — поиск по письмам
|
||||
- **STORE** — изменение флагов (прочитано, помечено, удалено)
|
||||
- **EXPUNGE** — удаление помеченных писем
|
||||
- **LIST / LSUB** — список папок
|
||||
- **STATUS** — информация о папке (количество писем, непрочитанных)
|
||||
- **CAPABILITY** — список поддерживаемых возможностей
|
||||
- **LOGOUT** — выход из сессии
|
||||
|
||||
Порт IMAP настраивается через `mailserver.imap.port` в `application.properties` (по умолчанию 143).
|
||||
|
||||
### DKIM / SPF / DMARC — защита от подделки писем
|
||||
|
||||
Почтовый сервер поддерживает три стандарта защиты электронной почты:
|
||||
|
||||
**DKIM (DomainKeys Identified Mail)** — криптографическая подпись писем.
|
||||
- При отправке письма автоматически подписываются RSA-ключом (2048 бит)
|
||||
- При получении входящих писем проверяется DKIM-подпись отправителя
|
||||
- Ключи хранятся в директории `./dkim-keys/` (генерируются автоматически при первом запуске)
|
||||
- Публичный ключ публикуется в DNS TXT-записи: `_default._domainkey.<domain>`
|
||||
|
||||
**SPF (Sender Policy Framework)** — проверка разрешённых серверов отправителя.
|
||||
- При получении письма проверяется SPF DNS-запись домена отправителя
|
||||
- Поддерживаемые механизмы: `all`, `ip4`, `ip6`, `mx`, `a`, `include`
|
||||
- Локальные адреса (127.0.0.1, 192.168.x.x) пропускаются без проверки
|
||||
|
||||
**DMARC (Domain-based Message Authentication, Reporting & Conformance)** — политика обработки.
|
||||
- Объединяет результаты SPF и DKIM с проверкой alignment (совпадение доменов)
|
||||
- Поддерживаемые политики: `none` (мониторинг), `quarantine` (спам), `reject` (отклонить)
|
||||
- Если SPF и DKIM не пройдены, применяется политика DMARC из DNS
|
||||
|
||||
**Результат проверки:**
|
||||
- Письма, не прошедшие SPF/DKIM/DMARC, автоматически помечаются флагом `spam: true`
|
||||
- Логируются результаты всех трёх проверок для каждого входящего письма
|
||||
|
||||
**Настройки в `application.properties`:**
|
||||
```
|
||||
mailserver.dkim.enabled=true
|
||||
mailserver.dkim.domain=localhost
|
||||
mailserver.dkim.keys-dir=./dkim-keys
|
||||
mailserver.spf.enabled=true
|
||||
mailserver.spf.local-bypass=true
|
||||
mailserver.dmarc.enabled=true
|
||||
mailserver.dmarc.local-bypass=true
|
||||
```
|
||||
|
||||
**Публикация DKIM-ключа в DNS:**
|
||||
При первом запуске сервер выводит в лог публичный ключ DKIM. Добавьте его как TXT-запись:
|
||||
```
|
||||
_name: _default._domainkey.yourdomain.com
|
||||
type: TXT
|
||||
value: v=DKIM1; k=rsa; p=MIIBojANBgkqhkiG9w0BAQ...
|
||||
```
|
||||
|
||||
## Модели данных
|
||||
|
||||
| Сущность | Описание |
|
||||
|---|---|
|
||||
| **User** | Почтовый ящик, пароль, квота |
|
||||
| **Folder** | INBOX, Sent, Drafts, Trash, Spam |
|
||||
| **Message** | Заголовки, тело, флаги (read, flagged, spam) |
|
||||
| **BlacklistEntry** | Заблокированный отправитель, привязка к пользователю |
|
||||
| **Attachment** | Имя, MIME-тип, размер, путь к файлу |
|
||||
| **Contact** | Имя, email, телефон, компания |
|
||||
|
||||
## Деплой на VPS
|
||||
|
||||
### Caddy Docker Proxy
|
||||
|
||||
Проект использует **Caddy Docker Proxy** для автоматического HTTPS и reverse proxy. Caddy сам генерирует конфиг из `labels` в `docker-compose.prod.yml` — отдельный Caddyfile не нужен.
|
||||
|
||||
**Как это работает:**
|
||||
- Контейнер `mail-server` имеет `labels` с настройками Caddy
|
||||
- `lucaslorentz/caddy-docker-proxy` следит за Docker через socket и автоматически создаёт виртуальные хосты
|
||||
- HTTPS-сертификат от Let's Encrypt получается автоматически
|
||||
|
||||
**Ключевые labels:**
|
||||
|
||||
| Label | Описание |
|
||||
|-------|----------|
|
||||
| `caddy: mail.jambotron.com` | Домен, на котором доступен сервис |
|
||||
| `caddy.route.api.reverse_proxy` | Проксирует `/api/*` на Spring Boot (порт 8080) |
|
||||
| `caddy.route.static.root` | Корневая директория для Angular фронтенда |
|
||||
| `caddy.route.static.file_server` | Раздаёт статические файлы Angular |
|
||||
|
||||
**Запуск на VPS:**
|
||||
|
||||
```bash
|
||||
# Клонируйте репозиторий
|
||||
git clone https://gitea.jambotron.com/jambotron_gang/reactive-mail-server.git
|
||||
cd reactive-mail-server
|
||||
|
||||
# Соберите фронтенд
|
||||
cd frontend && npm install && npx ng build --configuration=production
|
||||
cd ..
|
||||
|
||||
# Запустите
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
**Перед запуском:**
|
||||
1. Замените `mail.jambotron.com` на ваш домен в `docker-compose.prod.yml`
|
||||
2. Настройте PTR (Reverse DNS) у хостера — IP → `mail.jambotron.com`
|
||||
3. Добавьте DNS-записи: A (`mail` → IP), MX (`@` → `mail.jambotron.com`), TXT (`v=spf1 ip4:<IP> -all`)
|
||||
4. Откройте порты: 80, 443, 25 (SMTP relay), 3025 (SMTP приём)
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] Хэширование паролей (BCrypt)
|
||||
- [x] Аутентификация (JWT)
|
||||
- [x] Приём SMTP (встроенный сервер на WebFlux TcpServer)
|
||||
- [x] IMAP-сервер (встроенный, порт 143)
|
||||
- [x] Вложения (загрузка/скачивание)
|
||||
- [x] Поиск по письмам (по теме, телу, отправителю, получателю)
|
||||
- [x] Angular фронтенд (v22 + Tailwind CSS + Synthwave дизайн)
|
||||
- [x] Антиспам (чёрный список отправителей)
|
||||
- [x] DKIM/SPF/DMARC (подпись, верификация, DNS-проверка)
|
||||
- [x] SMTP релей (доставка на внешнюю почту)
|
||||
- [x] Контакты (адресная книга)
|
||||
- [x] Reply / Forward
|
||||
- [x] Пагинация писем
|
||||
- [x] HTML/Markdown режим
|
||||
- [x] Caddy Docker Proxy (автоматический HTTPS)
|
||||
- [ ] TLS шифрование SMTP/IMAP
|
||||
- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети.
|
||||
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird.
|
||||
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
|
||||
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
|
||||
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча).
|
||||
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
|
||||
- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
+32
-14
@@ -1,17 +1,35 @@
|
||||
# Stage 1: Build
|
||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY backend/build.gradle ./
|
||||
COPY gradlew ./
|
||||
COPY gradle gradle
|
||||
COPY backend/src src
|
||||
RUN chmod +x gradlew
|
||||
RUN ./gradlew bootJar --no-daemon
|
||||
# Build context: repository root (docker compose: context ".", dockerfile backend/Dockerfile)
|
||||
# No Alpine images: on Jambotron staging VMs (kernel 6.18.55-virt) binaries from Alpine 3.24.3
|
||||
# (musl 1.2.6) die with "Bus error" — java survived, curl in the healthcheck did not (2026-11-05)
|
||||
|
||||
# Stage 2: Run
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
# 1. Web client (Angular) — served by the backend from classpath:/static
|
||||
FROM node:22-bookworm-slim AS web
|
||||
WORKDIR /web
|
||||
COPY frontend/package*.json frontend/.npmrc ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npx ng build --configuration=production
|
||||
|
||||
# 2. Backend
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build/libs/*.jar app.jar
|
||||
RUN mkdir -p /app/attachments /app/dkim-keys
|
||||
COPY gradlew settings.gradle ./
|
||||
COPY gradle gradle
|
||||
COPY backend/build.gradle backend/
|
||||
COPY backend/src backend/src
|
||||
COPY --from=web /web/dist/frontend/browser backend/src/main/resources/static
|
||||
RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test
|
||||
|
||||
# 3. Runtime: not root; data in /app/data (volume)
|
||||
FROM eclipse-temurin:21-jre
|
||||
# Ubuntu image, curl included; own user: "mail" already exists (uid 8)
|
||||
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \
|
||||
&& mkdir -p /app/data/attachments /app/data/dkim-keys && chown -R app:app /app/data
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/backend/build/libs/*.jar app.jar
|
||||
USER app
|
||||
ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt
|
||||
EXPOSE 8080 3025
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
||||
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
|
||||
|
||||
+14
-1
@@ -23,6 +23,8 @@ dependencies {
|
||||
// R2DBC — реактивный доступ к PostgreSQL (вместо JPA/Hibernate)
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
// health для проверки контейнера (только /actuator/health)
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
// SMTP сервер на Spring — встроенный, без внешних зависимостей
|
||||
implementation 'org.eclipse.angus:angus-mail:2.0.3'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
@@ -35,6 +37,12 @@ dependencies {
|
||||
implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1'
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||
|
||||
// DKIM: подпись исходящих (simple-java-mail) и проверка входящих (Apache James jDKIM)
|
||||
implementation 'org.simplejavamail:utils-mail-dkim:3.4.0'
|
||||
implementation 'org.apache.james.jdkim:apache-jdkim-library:0.5'
|
||||
// DNS: MX-записи для доставки
|
||||
implementation 'dnsjava:dnsjava:3.6.3'
|
||||
|
||||
// R2DBC PostgreSQL драйвер
|
||||
runtimeOnly 'org.postgresql:r2dbc-postgresql:1.0.4.RELEASE'
|
||||
|
||||
@@ -43,10 +51,15 @@ dependencies {
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'com.icegreen:greenmail:2.0.1'
|
||||
testImplementation 'com.icegreen:greenmail:2.1.15'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
// only the executable jar (the plain jar would make two files in build/libs)
|
||||
tasks.named('jar') {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -4,24 +4,90 @@ import com.mailserver.repository.UserRepository;
|
||||
import com.mailserver.service.UserService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Mailboxes the server needs on start:
|
||||
* <ul>
|
||||
* <li>the first mailbox on an empty database — {@code mailserver.admin.email} (default admin@<first domain>)
|
||||
* with {@code mailserver.admin.password};</li>
|
||||
* <li>the catch-all mailbox ({@code mailserver.catch-all}) if it does not exist yet — mail to unknown addresses is
|
||||
* stored there, so it must exist.</li>
|
||||
* </ul>
|
||||
* A generated password is appended to {@code mailserver.passwords-file} (mode 600, in the data volume) — read it once
|
||||
* and change the password. Without the file setting it is printed to the log once.
|
||||
*/
|
||||
@Configuration
|
||||
public class DataInitializer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
|
||||
|
||||
@Bean
|
||||
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository) {
|
||||
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
|
||||
@Value("${mailserver.admin.email:}") String email,
|
||||
@Value("${mailserver.admin.password:}") String password,
|
||||
@Value("${mailserver.passwords-file:}") String passwordsFile) {
|
||||
return args -> {
|
||||
userRepository.count().block(); // Ждём инициализации
|
||||
Path file = passwordsFile.isBlank() ? null : Path.of(passwordsFile);
|
||||
if (userRepository.count().block() == 0) {
|
||||
log.info("База данных пуста, создаю тестового пользователя...");
|
||||
userService.register("admin@mailserver.local", "admin123", "Администратор").block();
|
||||
log.info("✓ Тестовый пользователь создан: admin@mailserver.local / admin123");
|
||||
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(Locale.ROOT);
|
||||
create(userService, address, password, "Administrator", file);
|
||||
}
|
||||
String catchAll = domains.catchAll();
|
||||
if (catchAll != null && !Boolean.TRUE.equals(userRepository.existsByEmail(catchAll).block())) {
|
||||
create(userService, catchAll, "", "Mail trap", file);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static void create(UserService userService, String address, String password, String name, Path file)
|
||||
throws IOException {
|
||||
boolean generated = password.isBlank();
|
||||
String secret = generated ? randomPassword() : password;
|
||||
userService.register(address, secret, name).block();
|
||||
if (!generated) {
|
||||
log.info("Mailbox created: {}", address);
|
||||
} else if (file != null) {
|
||||
appendPassword(file, address, secret);
|
||||
log.warn("Mailbox created: {} — password in {} (change it)", address, file);
|
||||
} else {
|
||||
log.warn("Mailbox created: {} — password: {} (shown once, change it)", address, secret);
|
||||
}
|
||||
}
|
||||
|
||||
static void appendPassword(Path file, String address, String password) throws IOException {
|
||||
if (file.getParent() != null) {
|
||||
Files.createDirectories(file.getParent());
|
||||
}
|
||||
if (!Files.exists(file)) {
|
||||
Files.createFile(file);
|
||||
try {
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// not a POSIX file system (Windows) — nothing to restrict
|
||||
}
|
||||
}
|
||||
Files.writeString(file, Instant.now() + " " + address + " " + password + System.lineSeparator(),
|
||||
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
|
||||
}
|
||||
|
||||
private static String randomPassword() {
|
||||
byte[] bytes = new byte[18];
|
||||
new SecureRandom().nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@Configuration
|
||||
public class JwtConfig {
|
||||
|
||||
@Value("${jwt.secret:MailServerSecretKeyForJWTTokenGenerationMustBeLongEnough2024!}")
|
||||
private String jwtSecret;
|
||||
|
||||
/** JWT_SECRET from the environment, at least 32 bytes; no default — a known key would let anyone log in. */
|
||||
@Bean
|
||||
public JwtUtils jwtUtils() {
|
||||
public JwtUtils jwtUtils(@Value("${jwt.secret:}") String jwtSecret) {
|
||||
if (jwtSecret.getBytes(StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalStateException("jwt.secret (JWT_SECRET) must be set, at least 32 bytes");
|
||||
}
|
||||
return new JwtUtils(jwtSecret);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Domains whose mailboxes live on this server ({@code mailserver.domains}), and the optional catch-all mailbox
|
||||
* ({@code mailserver.catch-all}): when set, mail to ANY address is stored there and nothing is delivered outside —
|
||||
* a mail trap for dev/staging.
|
||||
*/
|
||||
@Component
|
||||
public class MailDomains {
|
||||
|
||||
private final Set<String> domains;
|
||||
private final String catchAll;
|
||||
|
||||
public MailDomains(@Value("${mailserver.domains:localhost}") String domains,
|
||||
@Value("${mailserver.catch-all:}") String catchAll) {
|
||||
this.domains = Arrays.stream(domains.split(","))
|
||||
.map(d -> d.trim().toLowerCase(Locale.ROOT))
|
||||
.filter(d -> !d.isEmpty())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
this.catchAll = catchAll.trim().toLowerCase(Locale.ROOT);
|
||||
if (this.domains.isEmpty()) {
|
||||
throw new IllegalStateException("mailserver.domains is empty");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLocal(String address) {
|
||||
return domains.contains(domainOf(address));
|
||||
}
|
||||
|
||||
/** Mailbox that receives everything, or null. */
|
||||
public String catchAll() {
|
||||
return catchAll.isEmpty() ? null : catchAll;
|
||||
}
|
||||
|
||||
public Set<String> domains() {
|
||||
return domains;
|
||||
}
|
||||
|
||||
public static String domainOf(String address) {
|
||||
int at = address == null ? -1 : address.lastIndexOf('@');
|
||||
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||
import com.mailserver.smtp.MailRouter;
|
||||
import com.mailserver.smtp.SmtpServer;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Locale;
|
||||
|
||||
/** SMTP server, outbound delivery client and the queue scheduler. */
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class MailServerConfig {
|
||||
|
||||
@Bean(initMethod = "start", destroyMethod = "stop")
|
||||
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public SmtpServer smtpServer(MailRouter router,
|
||||
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||
@Value("${mailserver.smtp.port:3025}") int port,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
|
||||
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
|
||||
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
|
||||
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router);
|
||||
}
|
||||
|
||||
/**
|
||||
* mailserver.outbound.mode: off | mx | smarthost. For smarthost — host, port, username, password,
|
||||
* tls (starttls | ssl | none).
|
||||
*/
|
||||
@Bean
|
||||
public SmtpDeliveryClient smtpDeliveryClient(@Value("${mailserver.outbound.mode:off}") String mode,
|
||||
@Value("${mailserver.outbound.host:}") String host,
|
||||
@Value("${mailserver.outbound.port:587}") int port,
|
||||
@Value("${mailserver.outbound.username:}") String username,
|
||||
@Value("${mailserver.outbound.password:}") String password,
|
||||
@Value("${mailserver.outbound.tls:starttls}") String tls,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
|
||||
@Value("${mailserver.outbound.timeout-ms:30000}") int timeoutMs) {
|
||||
SmtpDeliveryClient.Mode m = SmtpDeliveryClient.Mode.valueOf(mode.trim().toUpperCase(Locale.ROOT));
|
||||
if (m == SmtpDeliveryClient.Mode.SMARTHOST && host.isBlank()) {
|
||||
throw new IllegalStateException("mailserver.outbound.mode=smarthost needs mailserver.outbound.host");
|
||||
}
|
||||
return new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(m, host, port, username, password,
|
||||
SmtpDeliveryClient.Tls.valueOf(tls.trim().toUpperCase(Locale.ROOT)), hostname, timeoutMs, 25));
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@ package com.mailserver.config;
|
||||
|
||||
import com.mailserver.security.JwtAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
|
||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.authentication.HttpStatusServerEntryPoint;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||
@@ -16,15 +18,19 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||
private final String corsOrigins;
|
||||
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
|
||||
@org.springframework.beans.factory.annotation.Value("${mailserver.cors.origins:}") String corsOrigins) {
|
||||
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||
this.corsOrigins = corsOrigins;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(java.util.Arrays.asList("http://localhost:4200", "http://localhost:4201"));
|
||||
// The web client is served from the same origin; other origins only for development (ng serve)
|
||||
configuration.setAllowedOrigins(java.util.Arrays.stream(corsOrigins.split(",")).map(String::trim).filter(o -> !o.isEmpty()).toList());
|
||||
configuration.setAllowedMethods(java.util.Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(java.util.Arrays.asList("Authorization", "Content-Type", "Accept", "X-Requested-With"));
|
||||
configuration.setAllowCredentials(true);
|
||||
@@ -38,10 +44,17 @@ public class SecurityConfig {
|
||||
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
|
||||
http
|
||||
.csrf(ServerHttpSecurity.CsrfSpec::disable)
|
||||
// JWT only: without this an API call with no token gets 401 + "WWW-Authenticate: Basic",
|
||||
// and the browser shows its own login popup where our mailbox password does not work
|
||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||
.exceptionHandling(e -> e.authenticationEntryPoint(new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||
.authorizeExchange(exchange -> exchange
|
||||
.pathMatchers("/api/login", "/api/register").permitAll()
|
||||
.anyExchange().authenticated()
|
||||
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
||||
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
||||
// the web client (static files, SPA routes)
|
||||
.anyExchange().permitAll()
|
||||
)
|
||||
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION);
|
||||
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import com.mailserver.service.DkimService;
|
||||
import com.mailserver.service.DmarcService;
|
||||
import com.mailserver.service.MailService;
|
||||
import com.mailserver.service.SmtpRelayService;
|
||||
import com.mailserver.service.SpfService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.AddressException;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.tcp.TcpServer;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Встроенный SMTP-сервер на Spring WebFlux (TcpServer).
|
||||
* Поддерживает команды: HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT, RSET, NOOP.
|
||||
* Обрабатывает входящие письма и сохраняет их через MailService.
|
||||
*/
|
||||
@Configuration
|
||||
public class SmtpServerConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SmtpServerConfig.class);
|
||||
|
||||
private final MailService mailService;
|
||||
private final SmtpRelayService smtpRelayService;
|
||||
private final DkimService dkimService;
|
||||
private final SpfService spfService;
|
||||
private final DmarcService dmarcService;
|
||||
|
||||
@Value("${mailserver.smtp.port:3025}")
|
||||
private int smtpPort;
|
||||
|
||||
@Value("${mailserver.smtp.hostname:localhost}")
|
||||
private String smtpHostname;
|
||||
|
||||
@Value("${mailserver.smtp.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
private DisposableServer disposableServer;
|
||||
|
||||
private final Map<String, String> capabilities = new HashMap<>();
|
||||
|
||||
public SmtpServerConfig(MailService mailService, SmtpRelayService smtpRelayService,
|
||||
DkimService dkimService,
|
||||
SpfService spfService, DmarcService dmarcService) {
|
||||
this.mailService = mailService;
|
||||
this.smtpRelayService = smtpRelayService;
|
||||
this.dkimService = dkimService;
|
||||
this.spfService = spfService;
|
||||
this.dmarcService = dmarcService;
|
||||
capabilities.put("SIZE", "10485760");
|
||||
capabilities.put("8BITMIME", "");
|
||||
capabilities.put("ENHANCEDSTATUSCODES", "");
|
||||
capabilities.put("PIPELINING", "");
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
if (!enabled) {
|
||||
log.info("SMTP-сервер отключён (mailserver.smtp.enabled=false)");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
disposableServer = TcpServer.create()
|
||||
.port(smtpPort)
|
||||
.handle((inbound, outbound) ->
|
||||
outbound.sendString(Mono.just("220 " + smtpHostname + " ESMTP ready\r\n"))
|
||||
.then(handleSmtpSession(inbound, outbound))
|
||||
)
|
||||
.bind()
|
||||
.block(java.time.Duration.ofSeconds(10));
|
||||
log.info("SMTP-сервер запущен на порту {} (хост: {})", smtpPort, smtpHostname);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось запустить SMTP-сервер на порту {}: {}", smtpPort, e.getMessage());
|
||||
// Не бросаем исключение, чтобы приложение могло стартовать без SMTP
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (disposableServer != null) {
|
||||
try {
|
||||
disposableServer.dispose();
|
||||
} catch (Exception e) {
|
||||
log.warn("Ошибка при остановке SMTP-сервера: {}", e.getMessage());
|
||||
}
|
||||
log.info("SMTP-сервер остановлен");
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<Void> handleSmtpSession(reactor.netty.NettyInbound inbound,
|
||||
reactor.netty.NettyOutbound outbound) {
|
||||
SmtpSession session = new SmtpSession();
|
||||
|
||||
return inbound.receive()
|
||||
.asString(StandardCharsets.UTF_8)
|
||||
.doOnNext(line -> log.debug("SMTP: команда: {}", line.trim()))
|
||||
.concatMap(line -> {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
String[] parts = line.split("\\s+", 2);
|
||||
String command = parts[0].toUpperCase();
|
||||
String argument = parts.length > 1 ? parts[1] : "";
|
||||
|
||||
switch (command) {
|
||||
case "HELO": {
|
||||
session.helo = true;
|
||||
session.ehlo = false;
|
||||
session.hostname = argument;
|
||||
return Mono.just("250 " + smtpHostname + " Hello " + argument + "\r\n");
|
||||
}
|
||||
case "EHLO": {
|
||||
session.helo = true;
|
||||
session.ehlo = true;
|
||||
session.hostname = argument;
|
||||
return Mono.just(buildEhloResponse(argument));
|
||||
}
|
||||
case "MAIL": {
|
||||
if (!session.helo) {
|
||||
return Mono.just("503 Hello required first\r\n");
|
||||
}
|
||||
String mailFrom = extractEmail(argument);
|
||||
if (mailFrom == null) {
|
||||
return Mono.just("501 Syntax: MAIL FROM:<address>\r\n");
|
||||
}
|
||||
session.mailFrom = mailFrom;
|
||||
return Mono.just("250 OK\r\n");
|
||||
}
|
||||
case "RCPT": {
|
||||
if (!session.helo) {
|
||||
return Mono.just("503 Hello required first\r\n");
|
||||
}
|
||||
if (session.mailFrom == null) {
|
||||
return Mono.just("503 MAIL required first\r\n");
|
||||
}
|
||||
String rcptTo = extractEmail(argument);
|
||||
if (rcptTo == null) {
|
||||
return Mono.just("501 Syntax: RCPT TO:<address>\r\n");
|
||||
}
|
||||
session.recipients.add(rcptTo);
|
||||
return Mono.just("250 OK\r\n");
|
||||
}
|
||||
case "DATA": {
|
||||
if (!session.helo) {
|
||||
return Mono.just("503 Hello required first\r\n");
|
||||
}
|
||||
if (session.mailFrom == null) {
|
||||
return Mono.just("503 MAIL required first\r\n");
|
||||
}
|
||||
if (session.recipients.isEmpty()) {
|
||||
return Mono.just("503 RCPT required first\r\n");
|
||||
}
|
||||
session.dataReady = true;
|
||||
return Mono.just("354 Start mail input; end with <CRLF>.<CRLF>\r\n");
|
||||
}
|
||||
case "QUIT": {
|
||||
session.quit = true;
|
||||
return Mono.just("221 " + smtpHostname + " Bye\r\n");
|
||||
}
|
||||
case "RSET": {
|
||||
session.reset();
|
||||
return Mono.just("250 OK\r\n");
|
||||
}
|
||||
case "NOOP":
|
||||
return Mono.just("250 OK\r\n");
|
||||
case "VRFY":
|
||||
return Mono.just("252 Cannot VRFY user but will accept message\r\n");
|
||||
case "EXPN":
|
||||
return Mono.just("252 Cannot EXPN user but will accept message\r\n");
|
||||
default: {
|
||||
// Если в режиме DATA, накапливаем тело
|
||||
if (session.dataReady) {
|
||||
if (line.equals(".")) {
|
||||
session.dataReady = false;
|
||||
String body = session.dataBody.toString();
|
||||
for (String recipient : session.recipients) {
|
||||
try {
|
||||
processIncomingMail(session.mailFrom, recipient, body);
|
||||
} catch (Exception e) {
|
||||
log.error("SMTP: ошибка обработки письма для {}: {}", recipient, e.getMessage());
|
||||
}
|
||||
}
|
||||
session.reset();
|
||||
session.dataBody.setLength(0);
|
||||
return Mono.just("250 OK: message accepted\r\n");
|
||||
} else {
|
||||
if (line.startsWith(".")) {
|
||||
session.dataBody.append(line.substring(1));
|
||||
} else {
|
||||
session.dataBody.append(line);
|
||||
}
|
||||
session.dataBody.append("\n");
|
||||
return Mono.empty();
|
||||
}
|
||||
} else {
|
||||
return Mono.just("502 Command not implemented\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(s -> !s.isEmpty())
|
||||
.transform(outbound::sendString)
|
||||
.then();
|
||||
}
|
||||
|
||||
private String buildEhloResponse(String hostname) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("250-").append(smtpHostname).append(" Hello ").append(hostname).append("\r\n");
|
||||
for (Map.Entry<String, String> cap : capabilities.entrySet()) {
|
||||
if (cap.getValue() != null && !cap.getValue().isEmpty()) {
|
||||
sb.append("250-").append(cap.getKey()).append(" ").append(cap.getValue()).append("\r\n");
|
||||
} else {
|
||||
sb.append("250-").append(cap.getKey()).append("\r\n");
|
||||
}
|
||||
}
|
||||
sb.append("250 AUTH LOGIN PLAIN\r\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void processIncomingMail(String from, String recipient, String body) {
|
||||
try {
|
||||
Properties props = new Properties();
|
||||
Session mailSession = Session.getDefaultInstance(props);
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
||||
MimeMessage mimeMessage = new MimeMessage(mailSession, bais);
|
||||
|
||||
String senderEmail = from;
|
||||
String senderName = null;
|
||||
InternetAddress[] fromAddresses = (InternetAddress[]) mimeMessage.getFrom();
|
||||
if (fromAddresses != null && fromAddresses.length > 0) {
|
||||
InternetAddress fromAddr = fromAddresses[0];
|
||||
senderEmail = fromAddr.getAddress();
|
||||
senderName = fromAddr.getPersonal();
|
||||
}
|
||||
|
||||
String subject = mimeMessage.getSubject();
|
||||
if (subject == null) subject = "";
|
||||
|
||||
Object content = mimeMessage.getContent();
|
||||
String messageBody;
|
||||
boolean isHtml = false;
|
||||
|
||||
if (content instanceof String) {
|
||||
messageBody = (String) content;
|
||||
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
|
||||
messageBody = extractTextFromMultipart((jakarta.mail.internet.MimeMultipart) content);
|
||||
isHtml = messageBody != null && (messageBody.contains("<html") || messageBody.contains("<HTML"));
|
||||
} else {
|
||||
messageBody = content != null ? content.toString() : "";
|
||||
}
|
||||
if (messageBody == null) messageBody = "";
|
||||
|
||||
boolean dkimPassed = dkimService.verifyMessage(mimeMessage);
|
||||
String dkimDomain = null;
|
||||
if (dkimPassed) {
|
||||
String[] dkimHeaders = mimeMessage.getHeader("DKIM-Signature");
|
||||
if (dkimHeaders != null && dkimHeaders.length > 0 && !dkimHeaders[0].isEmpty()) {
|
||||
dkimDomain = extractDkimDomain(dkimHeaders[0]);
|
||||
}
|
||||
}
|
||||
|
||||
SpfService.SpfResult spfResult = spfService.checkSpf(senderEmail, "127.0.0.1");
|
||||
boolean spfPassed = spfResult == SpfService.SpfResult.PASS;
|
||||
log.info("SPF проверка для {}: {} ({})", senderEmail, spfResult, spfResult.getDescription());
|
||||
|
||||
DmarcService.DmarcResult dmarcResult = dmarcService.checkDmarc(
|
||||
senderEmail, "127.0.0.1", spfPassed, dkimPassed, dkimDomain);
|
||||
log.info("DMARC проверка для {}: {} ({})", senderEmail, dmarcResult, dmarcResult.getDescription());
|
||||
|
||||
boolean isSpam = dmarcService.shouldMarkAsSpam(dmarcResult)
|
||||
|| spfResult == SpfService.SpfResult.FAIL
|
||||
|| spfResult == SpfService.SpfResult.SOFTFAIL;
|
||||
|
||||
if (isSpam) {
|
||||
log.info("Письмо от {} помечено как спам (SPF: {}, DKIM: {}, DMARC: {})",
|
||||
senderEmail, spfResult, dkimPassed, dmarcResult);
|
||||
}
|
||||
|
||||
mailService.receiveMessage(senderEmail, senderName, recipient, subject, messageBody, isHtml, isSpam).block();
|
||||
log.info("SMTP: письмо сохранено от {} для {} (тема: {})", senderEmail, recipient, subject);
|
||||
|
||||
} catch (AddressException e) {
|
||||
log.error("SMTP: ошибка адреса: {}", e.getMessage());
|
||||
} catch (MessagingException e) {
|
||||
log.error("SMTP: ошибка парсинга MIME: {}", e.getMessage());
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Получатель не найден в системе — пытаемся доставить через SMTP релей
|
||||
log.info("SMTP: получатель {} не найден в системе, пытаемся доставить через релей", recipient);
|
||||
try {
|
||||
// Формируем MIME-контент для релея
|
||||
Properties props = new Properties();
|
||||
Session mailSession = Session.getDefaultInstance(props);
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
||||
MimeMessage mimeMessage = new MimeMessage(mailSession, bais);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
mimeMessage.writeTo(baos);
|
||||
String mimeContent = baos.toString(StandardCharsets.UTF_8);
|
||||
|
||||
smtpRelayService.relayMessage(from, recipient, mimeContent).block();
|
||||
} catch (Exception relayEx) {
|
||||
log.error("SMTP: релей не смог доставить письмо для {}: {}", recipient, relayEx.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("SMTP: ошибка обработки письма: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String extractTextFromMultipart(jakarta.mail.internet.MimeMultipart multipart)
|
||||
throws MessagingException, IOException {
|
||||
StringBuilder text = new StringBuilder();
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
var bodyPart = multipart.getBodyPart(i);
|
||||
String contentType = bodyPart.getContentType();
|
||||
if (contentType != null && contentType.toLowerCase().contains("text")) {
|
||||
Object partContent = bodyPart.getContent();
|
||||
if (partContent instanceof String) {
|
||||
text.append(partContent);
|
||||
} else if (partContent instanceof InputStream) {
|
||||
try (InputStream is = (InputStream) partContent) {
|
||||
text.append(is.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return text.toString();
|
||||
}
|
||||
|
||||
private String extractDkimDomain(String dkimHeader) {
|
||||
int index = dkimHeader.indexOf("d=");
|
||||
if (index == -1) return null;
|
||||
int start = index + 2;
|
||||
int end = dkimHeader.indexOf(";", start);
|
||||
if (end == -1) end = dkimHeader.length();
|
||||
return dkimHeader.substring(start, end).trim();
|
||||
}
|
||||
|
||||
private String extractEmail(String argument) {
|
||||
int start = argument.indexOf('<');
|
||||
int end = argument.indexOf('>');
|
||||
if (start == -1 || end == -1) return null;
|
||||
return argument.substring(start + 1, end).trim().toLowerCase();
|
||||
}
|
||||
|
||||
private static class SmtpSession {
|
||||
boolean helo = false;
|
||||
boolean ehlo = false;
|
||||
boolean quit = false;
|
||||
String hostname;
|
||||
String mailFrom;
|
||||
List<String> recipients = new ArrayList<>();
|
||||
boolean dataReady = false;
|
||||
StringBuilder dataBody = new StringBuilder();
|
||||
|
||||
void reset() {
|
||||
mailFrom = null;
|
||||
recipients.clear();
|
||||
dataReady = false;
|
||||
dataBody.setLength(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
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).split(";")[0].contains(".");
|
||||
if (page) {
|
||||
return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build());
|
||||
}
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<String, Object> config() {
|
||||
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList());
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> 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<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> 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<ProfileResponse> me(Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(this::profile);
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
|
||||
return user(auth).zipWith(body)
|
||||
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
|
||||
.flatMap(this::profile);
|
||||
}
|
||||
|
||||
@PutMapping("/me/password")
|
||||
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> 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<ProfileResponse> 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> user(Mono<Authentication> 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,27 +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.service.SubmissionService;
|
||||
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;
|
||||
@@ -46,180 +49,149 @@ public class MailController {
|
||||
|
||||
private final UserService userService;
|
||||
private final MailService mailService;
|
||||
private final SubmissionService submissionService;
|
||||
|
||||
public MailController(UserService userService, MailService mailService) {
|
||||
public MailController(UserService userService, MailService mailService, SubmissionService submissionService) {
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.submissionService = submissionService;
|
||||
}
|
||||
|
||||
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.map(Authentication::getName);
|
||||
private Mono<com.mailserver.domain.User> user(Mono<Authentication> auth) {
|
||||
return auth.flatMap(a -> userService.findByEmail(a.getName()));
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
@PostMapping("/register")
|
||||
public Mono<?> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
|
||||
return requestMono.flatMap(request ->
|
||||
userService.register(request.getEmail(), 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<FolderResponse> getFolders(Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMapMany(auth ->
|
||||
userService.findByEmail(auth.getName())
|
||||
.flatMapMany(user -> userService.getFolders(user.getId()))
|
||||
);
|
||||
public Flux<FolderResponse> getFolders(Mono<Authentication> auth) {
|
||||
return user(auth).flatMapMany(user -> mailService.folders(user.getId()));
|
||||
}
|
||||
|
||||
@PostMapping("/folders")
|
||||
public Mono<ResponseEntity<FolderResponse>> createFolder(Mono<Authentication> auth, @Valid @RequestBody Mono<FolderRequest> 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<Map<String, String>> renameFolder(@PathVariable Long id, Mono<Authentication> auth,
|
||||
@Valid @RequestBody Mono<FolderRequest> 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<Map<String, String>> deleteFolder(@PathVariable Long id, Mono<Authentication> 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<Map<String, Object>> emptyFolder(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.emptyFolder(id, user.getId()))
|
||||
.map(count -> Map.<String, Object>of("deleted", count));
|
||||
}
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
/** Newest first, bodies cut to a preview. */
|
||||
@GetMapping("/messages")
|
||||
public Flux<MessageResponse> getMessages(
|
||||
Mono<Authentication> 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<MessageResponse> getMessages(Mono<Authentication> 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<MessageResponse> searchMessages(
|
||||
Mono<Authentication> authenticationMono,
|
||||
Mono<Authentication> 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<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
userService.findByEmail(auth.getName())
|
||||
.flatMap(user -> mailService.getMessage(id, user.getId()))
|
||||
);
|
||||
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.getMessage(id, user.getId()));
|
||||
}
|
||||
|
||||
@PostMapping("/send")
|
||||
public Mono<Object> sendMessage(
|
||||
Mono<Authentication> authenticationMono,
|
||||
@Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||
return authenticationMono.zipWith(requestMono)
|
||||
.flatMap(pair -> {
|
||||
String email = pair.getT1().getName();
|
||||
SendRequest request = pair.getT2();
|
||||
return mailService.sendMessage(email, request)
|
||||
.map(response -> (Object) org.springframework.http.ResponseEntity.ok(response))
|
||||
.onErrorResume(e -> {
|
||||
log.error("Ошибка при отправке письма: {}", e.getMessage(), e);
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
|
||||
.body(Map.of("error", e.getMessage())));
|
||||
});
|
||||
});
|
||||
/** read / flagged / folderId — only the fields that are set. */
|
||||
@PatchMapping("/messages/{id}")
|
||||
public Mono<MessageResponse> updateMessage(@PathVariable Long id, Mono<Authentication> auth,
|
||||
@RequestBody Mono<MessageUpdateRequest> 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<Authentication> 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<Map<String, String>> markAsRead(@PathVariable Long id, Mono<Authentication> 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<Authentication> 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<Map<String, String>> deleteMessage(@PathVariable Long id, Mono<Authentication> auth) {
|
||||
return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId()))
|
||||
.thenReturn(Map.of("message", "Message deleted"));
|
||||
}
|
||||
|
||||
// --- Simulate incoming message (для тестов) ---
|
||||
@PostMapping("/send")
|
||||
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
|
||||
return auth.zipWith(requestMono)
|
||||
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2()));
|
||||
}
|
||||
|
||||
@PostMapping("/simulate/receive")
|
||||
public Mono<?> simulateReceive(@RequestBody Mono<Map<String, String>> bodyMono) {
|
||||
return bodyMono.flatMap(body ->
|
||||
mailService.receiveMessage(
|
||||
body.get("senderEmail"),
|
||||
body.get("senderName"),
|
||||
body.get("recipientEmail"),
|
||||
body.get("subject"),
|
||||
body.get("body"),
|
||||
"true".equals(body.get("html"))
|
||||
)
|
||||
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message received"))))
|
||||
.onErrorResume(e ->
|
||||
Mono.just(org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage()))))
|
||||
);
|
||||
// --- Drafts ---
|
||||
|
||||
@PostMapping("/drafts")
|
||||
public Mono<MessageResponse> createDraft(Mono<Authentication> auth, @Valid @RequestBody Mono<DraftRequest> body) {
|
||||
return user(auth).zipWith(body).flatMap(pair -> mailService.saveDraft(pair.getT1(), null, pair.getT2()));
|
||||
}
|
||||
|
||||
@PutMapping("/drafts/{id}")
|
||||
public Mono<MessageResponse> updateDraft(@PathVariable Long id, Mono<Authentication> auth,
|
||||
@Valid @RequestBody Mono<DraftRequest> 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<ResponseEntity<AttachmentResponse>> uploadAttachment(
|
||||
@PathVariable Long messageId,
|
||||
Mono<Authentication> authenticationMono,
|
||||
@RequestBody Mono<Flux<FilePart>> 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 = 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().toString(),
|
||||
bytes.length, filePath.toString()))
|
||||
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
|
||||
});
|
||||
});
|
||||
});
|
||||
Mono<Authentication> auth,
|
||||
@RequestPart("file") Mono<FilePart> 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")
|
||||
@@ -233,39 +205,40 @@ public class MailController {
|
||||
}
|
||||
|
||||
@GetMapping("/attachments/{attachmentId}/download")
|
||||
public Mono<Object> downloadAttachment(
|
||||
public Mono<ResponseEntity<byte[]>> downloadAttachment(
|
||||
@PathVariable Long attachmentId,
|
||||
Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
userService.findByEmail(auth.getName())
|
||||
.flatMap(user -> {
|
||||
try {
|
||||
AttachmentResponse info = mailService.getAttachmentInfo(attachmentId, user.getId());
|
||||
Path filePath = Paths.get(mailService.getAttachmentFilePath(attachmentId, user.getId()));
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = Files.readAllBytes(filePath);
|
||||
} catch (IOException e) {
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "Failed to read file: " + e.getMessage())));
|
||||
}
|
||||
DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + info.getFilename() + "\"")
|
||||
.contentType(MediaType.parseMediaType(
|
||||
info.getContentType() != null ? info.getContentType() : "application/octet-stream"))
|
||||
.body(buffer)
|
||||
);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Mono.just((Object) org.springframework.http.ResponseEntity.badRequest().body(Map.of("error", e.getMessage())));
|
||||
}
|
||||
})
|
||||
);
|
||||
return authenticationMono.flatMap(auth -> userService.findByEmail(auth.getName()))
|
||||
.flatMap(user -> mailService.getAttachment(attachmentId, user.getId()))
|
||||
.flatMap(attachment -> Mono.fromCallable(() -> Files.readAllBytes(Paths.get(attachment.getFilePath())))
|
||||
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
|
||||
.map(bytes -> ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
.filename(attachment.getFilename(), java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(contentType(attachment.getContentType()))
|
||||
.body(bytes)));
|
||||
}
|
||||
|
||||
private static MediaType contentType(String value) {
|
||||
try {
|
||||
return value == null ? MediaType.APPLICATION_OCTET_STREAM : MediaType.parseMediaType(value);
|
||||
} catch (org.springframework.util.InvalidMimeTypeException e) {
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
|
||||
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("..")) {
|
||||
return "file";
|
||||
}
|
||||
return base.length() > 200 ? base.substring(base.length() - 200) : base;
|
||||
}
|
||||
|
||||
@DeleteMapping("/attachments/{attachmentId}")
|
||||
public Mono<?> deleteAttachment(
|
||||
public Mono<ResponseEntity<?>> deleteAttachment(
|
||||
@PathVariable Long attachmentId,
|
||||
Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
@@ -333,7 +306,7 @@ public class MailController {
|
||||
}
|
||||
|
||||
@DeleteMapping("/contacts/{id}")
|
||||
public Mono<?> deleteContact(
|
||||
public Mono<ResponseEntity<?>> deleteContact(
|
||||
@PathVariable Long id,
|
||||
Mono<Authentication> authenticationMono) {
|
||||
return authenticationMono.flatMap(auth ->
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Sends queued messages. Temporary failure (4xx, no connection, DNS) — retry later with growing pauses,
|
||||
* about 22 hours in total; permanent failure (5xx) or retries used up — FAILED and a bounce to the sender's inbox.
|
||||
* Runs on the scheduler thread: blocking here is fine, it is not the network event loop.
|
||||
*/
|
||||
@Component
|
||||
public class DeliveryWorker {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DeliveryWorker.class);
|
||||
|
||||
static final List<Duration> RETRY_PAUSES = List.of(
|
||||
Duration.ofMinutes(1), Duration.ofMinutes(5), Duration.ofMinutes(15), Duration.ofHours(1),
|
||||
Duration.ofHours(3), Duration.ofHours(6), Duration.ofHours(12));
|
||||
private static final int BATCH = 20;
|
||||
|
||||
/** Tells the sender that a message did not reach someone. */
|
||||
public interface BounceSender {
|
||||
void bounce(OutboundMessage message, String reason);
|
||||
}
|
||||
|
||||
private final OutboundRepository repository;
|
||||
private final SmtpDeliveryClient client;
|
||||
private final BounceSender bounces;
|
||||
|
||||
public DeliveryWorker(OutboundRepository repository, SmtpDeliveryClient client, BounceSender bounces) {
|
||||
this.repository = repository;
|
||||
this.client = client;
|
||||
this.bounces = bounces;
|
||||
}
|
||||
|
||||
@Scheduled(initialDelayString = "${mailserver.queue.initial-delay-ms:5000}", fixedDelayString = "${mailserver.queue.poll-ms:15000}")
|
||||
public void deliverDue() {
|
||||
List<OutboundMessage> due = repository.findDue(Instant.now(), BATCH).collectList().block();
|
||||
if (due == null) {
|
||||
return;
|
||||
}
|
||||
for (OutboundMessage message : due) {
|
||||
try {
|
||||
attempt(message, Instant.now());
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Queue item {} not processed: {}", message.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void attempt(OutboundMessage message, Instant now) {
|
||||
SmtpDeliveryClient.Result result = client.deliver(message.getMailFrom(), message.getRcptTo(), message.getMessage());
|
||||
message.setAttempts(message.getAttempts() + 1);
|
||||
message.setLastError(result.outcome() == SmtpDeliveryClient.Outcome.DELIVERED ? null : truncate(result.detail()));
|
||||
switch (result.outcome()) {
|
||||
case DELIVERED -> finish(message, OutboundMessage.DELIVERED, now);
|
||||
case PERMANENT -> fail(message, now, result.detail());
|
||||
case TEMPORARY -> {
|
||||
if (message.getAttempts() > RETRY_PAUSES.size()) {
|
||||
fail(message, now, "gave up after " + message.getAttempts() + " attempts: " + result.detail());
|
||||
} else {
|
||||
message.setNextAttemptAt(now.plus(RETRY_PAUSES.get(message.getAttempts() - 1)));
|
||||
repository.save(message).block();
|
||||
log.info("Queue item {} to {}: retry at {} ({})", message.getId(), message.getRcptTo(),
|
||||
message.getNextAttemptAt(), result.detail());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(OutboundMessage message, Instant now, String reason) {
|
||||
finish(message, OutboundMessage.FAILED, now);
|
||||
log.warn("Queue item {} to {} FAILED: {}", message.getId(), message.getRcptTo(), reason);
|
||||
try {
|
||||
bounces.bounce(message, reason);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Bounce for queue item {} not stored: {}", message.getId(), e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private void finish(OutboundMessage message, String status, Instant now) {
|
||||
message.setStatus(status);
|
||||
message.setFinishedAt(now);
|
||||
repository.save(message).block();
|
||||
}
|
||||
|
||||
private static String truncate(String text) {
|
||||
return text == null || text.length() <= 1000 ? text : text.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.relational.core.mapping.Column;
|
||||
import org.springframework.data.relational.core.mapping.Table;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Table("outbound_queue")
|
||||
public class OutboundMessage {
|
||||
|
||||
public static final String PENDING = "PENDING";
|
||||
public static final String DELIVERED = "DELIVERED";
|
||||
public static final String FAILED = "FAILED";
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
@Column("mail_from")
|
||||
private String mailFrom;
|
||||
@Column("rcpt_to")
|
||||
private String rcptTo;
|
||||
@Column("message")
|
||||
private byte[] message;
|
||||
@Column("status")
|
||||
private String status = PENDING;
|
||||
@Column("attempts")
|
||||
private int attempts;
|
||||
@Column("next_attempt_at")
|
||||
private Instant nextAttemptAt;
|
||||
@Column("last_error")
|
||||
private String lastError;
|
||||
@Column("created_at")
|
||||
private Instant createdAt;
|
||||
@Column("finished_at")
|
||||
private Instant finishedAt;
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
public String getMailFrom() { return mailFrom; }
|
||||
public void setMailFrom(String mailFrom) { this.mailFrom = mailFrom; }
|
||||
public String getRcptTo() { return rcptTo; }
|
||||
public void setRcptTo(String rcptTo) { this.rcptTo = rcptTo; }
|
||||
public byte[] getMessage() { return message; }
|
||||
public void setMessage(byte[] message) { this.message = message; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
public int getAttempts() { return attempts; }
|
||||
public void setAttempts(int attempts) { this.attempts = attempts; }
|
||||
public Instant getNextAttemptAt() { return nextAttemptAt; }
|
||||
public void setNextAttemptAt(Instant nextAttemptAt) { this.nextAttemptAt = nextAttemptAt; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
public Instant getFinishedAt() { return finishedAt; }
|
||||
public void setFinishedAt(Instant finishedAt) { this.finishedAt = finishedAt; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Puts a message for one external recipient into the delivery queue (the worker sends it). */
|
||||
@Service
|
||||
public class OutboundQueue {
|
||||
|
||||
private final OutboundRepository repository;
|
||||
|
||||
public OutboundQueue(OutboundRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public Mono<OutboundMessage> enqueue(String mailFrom, String rcptTo, byte[] signedMessage) {
|
||||
OutboundMessage message = new OutboundMessage();
|
||||
message.setMailFrom(mailFrom);
|
||||
message.setRcptTo(rcptTo);
|
||||
message.setMessage(signedMessage);
|
||||
message.setStatus(OutboundMessage.PENDING);
|
||||
message.setCreatedAt(Instant.now());
|
||||
message.setNextAttemptAt(Instant.now());
|
||||
return repository.save(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import org.springframework.data.r2dbc.repository.Query;
|
||||
import org.springframework.data.r2dbc.repository.R2dbcRepository;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public interface OutboundRepository extends R2dbcRepository<OutboundMessage, Long> {
|
||||
|
||||
@Query("SELECT * FROM outbound_queue WHERE status = 'PENDING' AND next_attempt_at <= :now ORDER BY next_attempt_at LIMIT :limit")
|
||||
Flux<OutboundMessage> findDue(Instant now, int limit);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import jakarta.mail.Address;
|
||||
import jakarta.mail.AuthenticationFailedException;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.SendFailedException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.eclipse.angus.mail.smtp.SMTPAddressFailedException;
|
||||
import org.eclipse.angus.mail.smtp.SMTPSendFailedException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.xbill.DNS.Lookup;
|
||||
import org.xbill.DNS.MXRecord;
|
||||
import org.xbill.DNS.Record;
|
||||
import org.xbill.DNS.TextParseException;
|
||||
import org.xbill.DNS.Type;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Delivers one message to one recipient over SMTP (Jakarta Mail), blocking.
|
||||
* <ul>
|
||||
* <li><b>mx</b> — straight to the recipient's MX servers on port 25, lowest preference first; STARTTLS when offered,
|
||||
* without certificate checks — opportunistic, like Postfix {@code smtp_tls_security_level=may};</li>
|
||||
* <li><b>smarthost</b> — everything through one provider (Resend, SES, …) with login and verified TLS;</li>
|
||||
* <li><b>off</b> — nothing leaves the server.</li>
|
||||
* </ul>
|
||||
* The message bytes are sent exactly as given (they carry the DKIM signature).
|
||||
*/
|
||||
public class SmtpDeliveryClient {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SmtpDeliveryClient.class);
|
||||
|
||||
public enum Mode { OFF, MX, SMARTHOST }
|
||||
|
||||
public enum Tls { NONE, STARTTLS, SSL }
|
||||
|
||||
/** port — of the smarthost; mxPort — of recipients' MX servers (25; another value only in tests). */
|
||||
public record Settings(Mode mode, String host, int port, String username, String password, Tls tls,
|
||||
String heloName, int timeoutMs, int mxPort) {
|
||||
}
|
||||
|
||||
public enum Outcome { DELIVERED, TEMPORARY, PERMANENT }
|
||||
|
||||
public record Result(Outcome outcome, String detail) {
|
||||
static Result delivered(String detail) { return new Result(Outcome.DELIVERED, detail); }
|
||||
static Result temporary(String detail) { return new Result(Outcome.TEMPORARY, detail); }
|
||||
static Result permanent(String detail) { return new Result(Outcome.PERMANENT, detail); }
|
||||
}
|
||||
|
||||
/** MX hosts of a domain, best first; empty — the domain has no mail server. */
|
||||
public interface MxResolver {
|
||||
List<String> resolve(String domain) throws TemporaryDnsException;
|
||||
}
|
||||
|
||||
public static class TemporaryDnsException extends Exception {
|
||||
public TemporaryDnsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private final MxResolver resolver;
|
||||
|
||||
public SmtpDeliveryClient(Settings settings) {
|
||||
this(settings, SmtpDeliveryClient::dnsMx);
|
||||
}
|
||||
|
||||
public SmtpDeliveryClient(Settings settings, MxResolver resolver) {
|
||||
this.settings = settings;
|
||||
this.resolver = resolver;
|
||||
}
|
||||
|
||||
public Mode mode() {
|
||||
return settings.mode();
|
||||
}
|
||||
|
||||
public Result deliver(String from, String recipient, byte[] message) {
|
||||
return switch (settings.mode()) {
|
||||
case OFF -> Result.permanent("outbound delivery is disabled on this server");
|
||||
case SMARTHOST -> send(settings.host(), settings.port(), true, from, recipient, message);
|
||||
case MX -> viaMx(from, recipient, message);
|
||||
};
|
||||
}
|
||||
|
||||
private Result viaMx(String from, String recipient, byte[] message) {
|
||||
String domain = recipient.substring(recipient.lastIndexOf('@') + 1).toLowerCase(Locale.ROOT);
|
||||
List<String> hosts;
|
||||
try {
|
||||
hosts = resolver.resolve(domain);
|
||||
} catch (TemporaryDnsException e) {
|
||||
return Result.temporary("DNS: " + e.getMessage());
|
||||
}
|
||||
if (hosts.isEmpty()) {
|
||||
return Result.permanent("domain " + domain + " does not accept mail (no MX/A record)");
|
||||
}
|
||||
Result last = null;
|
||||
for (String host : hosts) {
|
||||
last = send(host, settings.mxPort(), false, from, recipient, message);
|
||||
// A server answered with a decision — do not ask the next MX; only a connection problem moves on
|
||||
if (last.outcome() != Outcome.TEMPORARY || last.detail().startsWith("4")) {
|
||||
return last;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private Result send(String host, int port, boolean smarthost, String from, String recipient, byte[] message) {
|
||||
Properties props = new Properties();
|
||||
props.put("mail.smtp.host", host);
|
||||
props.put("mail.smtp.port", String.valueOf(port));
|
||||
props.put("mail.smtp.localhost", settings.heloName());
|
||||
props.put("mail.smtp.connectiontimeout", String.valueOf(settings.timeoutMs()));
|
||||
props.put("mail.smtp.timeout", String.valueOf(settings.timeoutMs()));
|
||||
props.put("mail.smtp.writetimeout", String.valueOf(settings.timeoutMs()));
|
||||
props.put("mail.smtp.from", from);
|
||||
props.put("mail.smtp.allow8bitmime", "true");
|
||||
boolean login = false;
|
||||
if (smarthost) {
|
||||
switch (settings.tls()) {
|
||||
case STARTTLS -> {
|
||||
props.put("mail.smtp.starttls.enable", "true");
|
||||
props.put("mail.smtp.starttls.required", "true");
|
||||
}
|
||||
case SSL -> props.put("mail.smtp.ssl.enable", "true");
|
||||
case NONE -> { }
|
||||
}
|
||||
props.put("mail.smtp.ssl.checkserveridentity", "true");
|
||||
login = settings.username() != null && !settings.username().isBlank();
|
||||
props.put("mail.smtp.auth", String.valueOf(login));
|
||||
} else {
|
||||
props.put("mail.smtp.starttls.enable", "true");
|
||||
props.put("mail.smtp.ssl.trust", "*");
|
||||
}
|
||||
Session session = Session.getInstance(props);
|
||||
try (Transport transport = session.getTransport(settings.tls() == Tls.SSL && smarthost ? "smtps" : "smtp")) {
|
||||
if (login) {
|
||||
transport.connect(host, port, settings.username(), settings.password());
|
||||
} else {
|
||||
transport.connect(host, port, null, null);
|
||||
}
|
||||
transport.sendMessage(new RawMessage(session, message), new Address[]{new InternetAddress(recipient)});
|
||||
log.info("Delivered {} -> {} via {}:{}", from, recipient, host, port);
|
||||
return Result.delivered(host);
|
||||
} catch (AuthenticationFailedException e) {
|
||||
return Result.temporary("login to " + host + " failed: " + e.getMessage());
|
||||
} catch (MessagingException e) {
|
||||
int code = replyCode(e);
|
||||
String text = code > 0 ? code + " " + oneLine(e) : "connection to " + host + ": " + oneLine(e);
|
||||
log.warn("Delivery {} -> {} via {}: {}", from, recipient, host, text);
|
||||
return code >= 500 ? Result.permanent(text) : Result.temporary(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** SMTP reply code from the exception chain, 0 — no reply (connection problem). */
|
||||
static int replyCode(Exception e) {
|
||||
Exception current = e;
|
||||
while (current != null) {
|
||||
if (current instanceof SMTPAddressFailedException f) {
|
||||
return f.getReturnCode();
|
||||
}
|
||||
if (current instanceof SMTPSendFailedException f) {
|
||||
return f.getReturnCode();
|
||||
}
|
||||
current = current instanceof MessagingException m ? m.getNextException()
|
||||
: current.getCause() instanceof Exception c ? c : null;
|
||||
if (current instanceof SendFailedException sf && sf.getInvalidAddresses() != null
|
||||
&& sf.getNextException() == null) {
|
||||
return 550;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static String oneLine(Exception e) {
|
||||
String message = String.valueOf(e.getMessage()).replaceAll("\\s+", " ").trim();
|
||||
return message.length() > 500 ? message.substring(0, 500) : message;
|
||||
}
|
||||
|
||||
/** MX hosts by preference (RFC 5321 5.1); no MX — the domain itself (implicit MX); null MX "." — none. */
|
||||
static List<String> dnsMx(String domain) throws TemporaryDnsException {
|
||||
try {
|
||||
Lookup lookup = new Lookup(domain, Type.MX);
|
||||
Record[] records = lookup.run();
|
||||
switch (lookup.getResult()) {
|
||||
case Lookup.SUCCESSFUL -> {
|
||||
List<String> hosts = Arrays.stream(records)
|
||||
.map(MXRecord.class::cast)
|
||||
.sorted(Comparator.comparingInt(MXRecord::getPriority))
|
||||
.map(mx -> mx.getTarget().toString(true))
|
||||
.filter(host -> !host.isEmpty() && !host.equals("."))
|
||||
.toList();
|
||||
return hosts;
|
||||
}
|
||||
case Lookup.TYPE_NOT_FOUND -> {
|
||||
Lookup a = new Lookup(domain, Type.A);
|
||||
a.run();
|
||||
return a.getResult() == Lookup.SUCCESSFUL ? List.of(domain) : List.of();
|
||||
}
|
||||
case Lookup.HOST_NOT_FOUND -> {
|
||||
return List.of();
|
||||
}
|
||||
default -> throw new TemporaryDnsException(lookup.getErrorString());
|
||||
}
|
||||
} catch (TextParseException e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** A message written to the wire byte for byte as received (keeps the DKIM signature valid). */
|
||||
static final class RawMessage extends MimeMessage {
|
||||
private final byte[] raw;
|
||||
|
||||
RawMessage(Session session, byte[] raw) throws MessagingException {
|
||||
super(session, new ByteArrayInputStream(raw));
|
||||
this.raw = raw;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(OutputStream os, String[] ignoreHeaders) throws IOException {
|
||||
os.write(raw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(OutputStream os) throws IOException {
|
||||
os.write(raw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSize() {
|
||||
return raw.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.mailserver.dev;
|
||||
|
||||
import com.mailserver.service.MailService;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** Dev profile only: put a message into a mailbox without SMTP (any sender — never on a real server). */
|
||||
@RestController
|
||||
@Profile("dev")
|
||||
public class DevController {
|
||||
|
||||
private final MailService mailService;
|
||||
|
||||
public DevController(MailService mailService) {
|
||||
this.mailService = mailService;
|
||||
}
|
||||
|
||||
@PostMapping("/api/simulate/receive")
|
||||
public Mono<ResponseEntity<Map<String, String>>> simulateReceive(@RequestBody Map<String, String> body) {
|
||||
return mailService.receiveMessage(body.get("senderEmail"), body.get("senderName"), body.get("recipientEmail"),
|
||||
body.getOrDefault("subject", ""), body.getOrDefault("body", ""), "true".equals(body.get("html")))
|
||||
.thenReturn(ResponseEntity.ok(Map.of("message", "Message received")))
|
||||
.onErrorResume(e -> Mono.just(ResponseEntity.badRequest().body(Map.of("error", String.valueOf(e.getMessage())))));
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<Attachment, Long> {
|
||||
Flux<Attachment> findByMessageId(Long messageId);
|
||||
|
||||
Mono<Long> countByMessageId(Long messageId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
|
||||
Mono<Integer> 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<Long> sumSizeByUserId(Long userId);
|
||||
}
|
||||
|
||||
@@ -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<Message, Long> {
|
||||
|
||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC LIMIT :#{#limit}")
|
||||
Flux<Message> findLatest(Long folderId, int limit);
|
||||
|
||||
Flux<Message> 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<Message> findByFolderId(Long folderId, Pageable pageable);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}")
|
||||
Mono<Long> countByFolderId(Long folderId);
|
||||
|
||||
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false")
|
||||
Mono<Long> countUnreadByFolderId(Long folderId);
|
||||
|
||||
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC")
|
||||
Flux<Message> findUnreadByFolderId(Long folderId, Pageable pageable);
|
||||
@Modifying
|
||||
@Query("UPDATE messages SET folder_id = :#{#to} WHERE folder_id = :#{#from}")
|
||||
Mono<Integer> 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<Long> 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<Message> searchInFolder(Long folderId, String keyword, Pageable pageable);
|
||||
|
||||
@@ -1,458 +1,209 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.apache.james.jdkim.DKIMVerifier;
|
||||
import org.apache.james.jdkim.api.PublicKeyRecordRetriever;
|
||||
import org.apache.james.jdkim.api.SignatureRecord;
|
||||
import org.apache.james.jdkim.exceptions.FailException;
|
||||
import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever;
|
||||
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||
import org.bouncycastle.openssl.PEMKeyPair;
|
||||
import org.bouncycastle.openssl.PEMParser;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
|
||||
import org.simplejavamail.utils.mail.dkim.Canonicalization;
|
||||
import org.simplejavamail.utils.mail.dkim.DkimMessage;
|
||||
import org.simplejavamail.utils.mail.dkim.DkimSigner;
|
||||
import org.simplejavamail.utils.mail.dkim.SigningAlgorithm;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.*;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateCrtKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем.
|
||||
*
|
||||
* DKIM добавляет криптографическую подпись к каждому исходящему письму,
|
||||
* чтобы получатель мог проверить, что письмо действительно отправлено
|
||||
* владельцем домена и не было изменено в пути.
|
||||
*
|
||||
* При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл.
|
||||
* Подписывает заголовки From, Subject, Date, To и тело сообщения.
|
||||
* DKIM (RFC 6376): signs our outgoing mail and verifies incoming mail.
|
||||
* Signing — simple-java-mail utils-mail-dkim (rsa-sha256, relaxed/relaxed); verification — Apache James jDKIM
|
||||
* with the sender's public key from DNS. The key is generated once and kept in {@code keys-dir/dkim-private.pem};
|
||||
* the DNS record to publish is printed to the log at start and available from {@link #dnsRecord()}.
|
||||
*/
|
||||
@Service
|
||||
public class DkimService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
|
||||
|
||||
private static final String DKIM_SELECTOR = "default";
|
||||
/** Result of checking an incoming message. */
|
||||
public record Verification(boolean signed, boolean passed, String domain) {
|
||||
static final Verification NONE = new Verification(false, false, null);
|
||||
}
|
||||
|
||||
@Value("${mailserver.dkim.domain:localhost}")
|
||||
private String domain;
|
||||
private final String domain;
|
||||
private final String selector;
|
||||
private final Path keysDir;
|
||||
private final boolean enabled;
|
||||
private final DKIMVerifier verifier;
|
||||
|
||||
@Value("${mailserver.dkim.keys-dir:./dkim-keys}")
|
||||
private String keysDir;
|
||||
|
||||
@Value("${mailserver.dkim.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
private PrivateKey privateKey;
|
||||
private PublicKey publicKey;
|
||||
private RSAPrivateKey privateKey;
|
||||
private String publicKeyBase64;
|
||||
|
||||
@Autowired
|
||||
public DkimService(@Value("${mailserver.dkim.domain:localhost}") String domain,
|
||||
@Value("${mailserver.dkim.selector:mail}") String selector,
|
||||
@Value("${mailserver.dkim.keys-dir:./dkim-keys}") String keysDir,
|
||||
@Value("${mailserver.dkim.enabled:true}") boolean enabled) {
|
||||
this(domain, selector, Paths.get(keysDir), enabled, new DNSPublicKeyRecordRetriever());
|
||||
}
|
||||
|
||||
// Tests pass their own public key lookup instead of DNS
|
||||
DkimService(String domain, String selector, Path keysDir, boolean enabled, PublicKeyRecordRetriever keys) {
|
||||
this.domain = domain.toLowerCase(Locale.ROOT);
|
||||
this.selector = selector;
|
||||
this.keysDir = keysDir;
|
||||
this.enabled = enabled;
|
||||
this.verifier = new DKIMVerifier(keys);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
|
||||
if (!enabled) {
|
||||
log.info("DKIM подписи отключены");
|
||||
log.info("DKIM signing disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path keyDir = Paths.get(keysDir);
|
||||
Files.createDirectories(keyDir);
|
||||
|
||||
Path privateKeyFile = keyDir.resolve("dkim-private.pem");
|
||||
Path publicKeyFile = keyDir.resolve("dkim-public.pem");
|
||||
|
||||
if (Files.exists(privateKeyFile) && Files.exists(publicKeyFile)) {
|
||||
// Загружаем существующие ключи
|
||||
loadKeys(privateKeyFile, publicKeyFile);
|
||||
log.info("DKIM: загружены существующие ключи для домена {}", domain);
|
||||
Files.createDirectories(keysDir);
|
||||
Path keyFile = keysDir.resolve("dkim-private.pem");
|
||||
if (Files.exists(keyFile)) {
|
||||
privateKey = readPrivateKey(Files.readString(keyFile));
|
||||
log.info("DKIM: key loaded for {}", domain);
|
||||
} else {
|
||||
// Генерируем новые ключи
|
||||
KeyPair keyPair = generateKeyPair();
|
||||
privateKey = keyPair.getPrivate();
|
||||
publicKey = keyPair.getPublic();
|
||||
saveKeys(privateKeyFile, publicKeyFile, keyPair);
|
||||
log.info("DKIM: сгенерированы новые ключи для домена {}", domain);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
privateKey = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||
String pem = "-----BEGIN PRIVATE KEY-----\n"
|
||||
+ Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded())
|
||||
+ "\n-----END PRIVATE KEY-----\n";
|
||||
Files.writeString(keyFile, pem);
|
||||
restrict(keyFile);
|
||||
log.info("DKIM: new key generated for {}", domain);
|
||||
}
|
||||
|
||||
// Формируем публичный ключ в формате DNS TXT-записи
|
||||
publicKeyBase64 = encodePublicKeyForDns(publicKey);
|
||||
log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain);
|
||||
log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64);
|
||||
|
||||
publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyOf(privateKey));
|
||||
log.info("DKIM DNS record — TXT {}._domainkey.{}: {}", selector, domain, dnsRecord());
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка инициализации DKIM", e);
|
||||
throw new IllegalStateException("DKIM key cannot be loaded from " + keysDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует RSA-ключ 2048 бит для DKIM.
|
||||
*/
|
||||
private KeyPair generateKeyPair() throws NoSuchAlgorithmException {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
return keyPairGenerator.generateKeyPair();
|
||||
/** TXT value to publish at {@code <selector>._domainkey.<domain>}. */
|
||||
public String dnsRecord() {
|
||||
return publicKeyBase64 == null ? null : "v=DKIM1; k=rsa; p=" + publicKeyBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет ключи в PEM-формат.
|
||||
* Signs the message when it is from our domain; otherwise returns it unchanged.
|
||||
* The result is the exact byte sequence to send — do not re-serialize it.
|
||||
*/
|
||||
private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair)
|
||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
try (FileWriter fw = new FileWriter(privateKeyFile.toFile());
|
||||
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
|
||||
pemWriter.writeObject(keyPair.getPrivate());
|
||||
pemWriter.flush();
|
||||
}
|
||||
|
||||
try (FileWriter fw = new FileWriter(publicKeyFile.toFile());
|
||||
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
|
||||
pemWriter.writeObject(keyPair.getPublic());
|
||||
pemWriter.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает ключи из PEM-файлов.
|
||||
*/
|
||||
private void loadKeys(Path privateKeyFile, Path publicKeyFile)
|
||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
String pkcs8Private = Files.readString(privateKeyFile);
|
||||
String derEncoded = pkcs8Private
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(derEncoded);
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
privateKey = keyFactory.generatePrivate(spec);
|
||||
|
||||
String pkcs8Public = Files.readString(publicKeyFile);
|
||||
String derEncodedPublic = pkcs8Public
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decodedPublic = Base64.getDecoder().decode(derEncodedPublic);
|
||||
X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(decodedPublic);
|
||||
publicKey = keyFactory.generatePublic(pubSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Кодирует публичный ключ для DNS TXT-записи.
|
||||
*/
|
||||
private String encodePublicKeyForDns(PublicKey key) {
|
||||
byte[] encoded = key.getEncoded();
|
||||
return Base64.getEncoder().withoutPadding().encodeToString(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывает MIME-сообщение DKIM-подписью.
|
||||
* Добавляет заголовок DKIM-Signature в письмо.
|
||||
*
|
||||
* @param mimeMessage MIME-сообщение для подписи
|
||||
* @throws MessagingException если не удалось добавить заголовок
|
||||
*/
|
||||
public void signMessage(MimeMessage mimeMessage) throws MessagingException {
|
||||
public byte[] sign(byte[] message) throws MessagingException, IOException {
|
||||
if (!enabled || privateKey == null) {
|
||||
return;
|
||||
return message;
|
||||
}
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(message));
|
||||
if (mime.getFrom() == null || mime.getFrom().length == 0
|
||||
|| !(mime.getFrom()[0] instanceof InternetAddress from)
|
||||
|| !domain.equals(domainOf(from.getAddress()))) {
|
||||
return message;
|
||||
}
|
||||
DkimSigner signer = new DkimSigner(domain, selector, privateKey);
|
||||
signer.setCheckDomainKey(false); // the DNS record may not be published yet (tests, new server)
|
||||
signer.setSigningAlgorithm(SigningAlgorithm.SHA256_WITH_RSA);
|
||||
signer.setHeaderCanonicalization(Canonicalization.RELAXED);
|
||||
signer.setBodyCanonicalization(Canonicalization.RELAXED);
|
||||
signer.setLengthParam(false);
|
||||
signer.setCopyHeaderFields(false);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(message.length + 1024);
|
||||
DkimMessage signed = new DkimMessage(mime, signer);
|
||||
signed.setAllow8bitMIME(true);
|
||||
signed.writeTo(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** Checks DKIM signatures of an incoming message (public key from the signer's DNS). */
|
||||
public Verification verify(byte[] message) {
|
||||
try {
|
||||
// Извлекаем заголовки для подписи
|
||||
String from = extractHeader(mimeMessage, "From");
|
||||
String subject = extractHeader(mimeMessage, "Subject");
|
||||
String date = extractHeader(mimeMessage, "Date");
|
||||
String to = extractHeader(mimeMessage, "To");
|
||||
String messageId = extractHeader(mimeMessage, "Message-ID");
|
||||
|
||||
// Извлекаем тело сообщения
|
||||
String body = extractBody(mimeMessage);
|
||||
|
||||
// Формируем канонизированное тело (relaxed канонизация)
|
||||
String canonicalBody = canonicalizeBody(body);
|
||||
|
||||
// Формируем канонизированные заголовки
|
||||
String headerCanonicalization = "r";
|
||||
String bodyCanonicalization = "r";
|
||||
String algorithm = "rsa-sha256";
|
||||
|
||||
// Строим заголовки для подписи
|
||||
StringBuilder headersToSign = new StringBuilder();
|
||||
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
|
||||
String[] headerValues = {from, subject, date, to, messageId};
|
||||
|
||||
for (int i = 0; i < headerNames.length; i++) {
|
||||
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
|
||||
List<SignatureRecord> valid;
|
||||
synchronized (verifier) { // DKIMVerifier keeps results between calls
|
||||
verifier.resetResults();
|
||||
valid = verifier.verify(new ByteArrayInputStream(message));
|
||||
}
|
||||
|
||||
// Вычисляем хэш тела
|
||||
byte[] bodyHash = hashBody(canonicalBody);
|
||||
|
||||
// Формируем signing input
|
||||
String headerCanon = headersToSign.toString();
|
||||
byte[] signingInput = (headerCanon + "bh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash) + "\rbh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash)).getBytes();
|
||||
|
||||
// Подписываем
|
||||
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
|
||||
signature.initSign(privateKey);
|
||||
signature.update(signingInput);
|
||||
byte[] signatureBytes = signature.sign();
|
||||
|
||||
// Формируем DKIM-Signature заголовок
|
||||
String b64Signature = Base64.getEncoder().withoutPadding().encodeToString(signatureBytes);
|
||||
String b64BodyHash = Base64.getEncoder().withoutPadding().encodeToString(bodyHash);
|
||||
|
||||
StringBuilder dkimHeader = new StringBuilder();
|
||||
dkimHeader.append("v=1; ");
|
||||
dkimHeader.append("a=").append(algorithm).append("; ");
|
||||
dkimHeader.append("d=").append(domain).append("; ");
|
||||
dkimHeader.append("s=").append(DKIM_SELECTOR).append("; ");
|
||||
dkimHeader.append("c=").append(headerCanonicalization).append("/").append(bodyCanonicalization).append("; ");
|
||||
dkimHeader.append("h=").append(String.join(":", headerNames)).append("; ");
|
||||
dkimHeader.append("bh=").append(b64BodyHash).append("; ");
|
||||
dkimHeader.append("b=").append(b64Signature);
|
||||
|
||||
mimeMessage.setHeader("DKIM-Signature", dkimHeader.toString());
|
||||
log.debug("DKIM: сообщение подписано для домена {}", domain);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DKIM: ошибка подписи сообщения", e);
|
||||
// Не бросаем исключение — письмо будет отправлено без подписи
|
||||
if (valid == null) {
|
||||
return Verification.NONE;
|
||||
}
|
||||
if (valid.isEmpty()) {
|
||||
return new Verification(true, false, null);
|
||||
}
|
||||
return new Verification(true, true, valid.get(0).getDToken().toString().toLowerCase(Locale.ROOT));
|
||||
} catch (FailException e) {
|
||||
log.info("DKIM: signature failed: {}", e.getMessage());
|
||||
return new Verification(true, false, null);
|
||||
} catch (IOException e) {
|
||||
log.warn("DKIM: message cannot be read: {}", e.getMessage());
|
||||
return new Verification(true, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует DKIM-подпись входящего сообщения.
|
||||
*
|
||||
* @param mimeMessage MIME-сообщение с DKIM-подписью
|
||||
* @return true если подпись валидна, false если подпись отсутствует или невалидна
|
||||
*/
|
||||
public boolean verifyMessage(MimeMessage mimeMessage) {
|
||||
if (!enabled) {
|
||||
return true; // Если DKIM отключён, считаем все письма валидными
|
||||
}
|
||||
static String domainOf(String address) {
|
||||
int at = address == null ? -1 : address.lastIndexOf('@');
|
||||
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static RSAPrivateKey readPrivateKey(String pem) throws IOException {
|
||||
try (Reader reader = new StringReader(pem); PEMParser parser = new PEMParser(reader)) {
|
||||
Object object = parser.readObject();
|
||||
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
|
||||
// PKCS#8 ("PRIVATE KEY") and the older PKCS#1 ("RSA PRIVATE KEY") written by earlier versions
|
||||
if (object instanceof PEMKeyPair pair) {
|
||||
return (RSAPrivateKey) converter.getKeyPair(pair).getPrivate();
|
||||
}
|
||||
if (object instanceof PrivateKeyInfo info) {
|
||||
return (RSAPrivateKey) converter.getPrivateKey(info);
|
||||
}
|
||||
throw new IOException("Not an RSA private key: " + (object == null ? "empty file" : object.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] publicKeyOf(RSAPrivateKey key) throws Exception {
|
||||
RSAPrivateCrtKey crt = (RSAPrivateCrtKey) key;
|
||||
return KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent()))
|
||||
.getEncoded();
|
||||
}
|
||||
|
||||
private static void restrict(Path file) {
|
||||
try {
|
||||
String dkimHeader = mimeMessage.getHeader("DKIM-Signature")[0];
|
||||
if (dkimHeader == null || dkimHeader.isEmpty()) {
|
||||
log.debug("DKIM: подпись не найдена в сообщении");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Извлекаем домен из подписи
|
||||
String domain = extractDkimValue(dkimHeader, "d=");
|
||||
if (domain == null || domain.isEmpty()) {
|
||||
log.warn("DKIM: не удалось извлечь домен из подписи");
|
||||
return false;
|
||||
}
|
||||
|
||||
// В реальном мире здесь мы бы:
|
||||
// 1. Сделали DNS-запрос для _domainkey.<domain>
|
||||
// 2. Получили публичный ключ
|
||||
// 3. Проверили подпись
|
||||
//
|
||||
// Для локального сервера мы проверяем, что подпись присутствует
|
||||
// и имеет корректный формат
|
||||
String version = extractDkimValue(dkimHeader, "v=");
|
||||
String algorithm = extractDkimValue(dkimHeader, "a=");
|
||||
String selector = extractDkimValue(dkimHeader, "s=");
|
||||
String signature = extractDkimValue(dkimHeader, "b=");
|
||||
|
||||
if (version == null || !version.equals("1")) {
|
||||
log.warn("DKIM: неверная версия подписи");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (algorithm == null || !algorithm.startsWith("rsa-")) {
|
||||
log.warn("DKIM: неподдерживаемый алгоритм: {}", algorithm);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (signature == null || signature.isEmpty()) {
|
||||
log.warn("DKIM: подпись пуста");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем, что подпись — валидный Base64
|
||||
try {
|
||||
Base64.getDecoder().decode(signature);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("DKIM: подпись не является валидным Base64");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info("DKIM: подпись найдена для домена {} (селектор: {})", domain, selector);
|
||||
|
||||
// Если письмо подписано нашим собственным ключом, проверяем полностью
|
||||
if (domain.equals(this.domain) && privateKey != null) {
|
||||
return verifyLocalSignature(mimeMessage, dkimHeader);
|
||||
}
|
||||
|
||||
// Для внешних доменов проверяем только формат
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DKIM: ошибка верификации", e);
|
||||
return false;
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||
} catch (UnsupportedOperationException | IOException ignored) {
|
||||
// not a POSIX file system
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует подпись локального домена.
|
||||
*/
|
||||
private boolean verifyLocalSignature(MimeMessage mimeMessage, String dkimHeader) {
|
||||
try {
|
||||
String from = extractHeader(mimeMessage, "From");
|
||||
String subject = extractHeader(mimeMessage, "Subject");
|
||||
String date = extractHeader(mimeMessage, "Date");
|
||||
String to = extractHeader(mimeMessage, "To");
|
||||
String messageId = extractHeader(mimeMessage, "Message-ID");
|
||||
String body = extractBody(mimeMessage);
|
||||
String canonicalBody = canonicalizeBody(body);
|
||||
|
||||
StringBuilder headersToSign = new StringBuilder();
|
||||
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
|
||||
String[] headerValues = {from, subject, date, to, messageId};
|
||||
|
||||
for (int i = 0; i < headerNames.length; i++) {
|
||||
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
|
||||
}
|
||||
|
||||
byte[] bodyHash = hashBody(canonicalBody);
|
||||
String b64BodyHash = extractDkimValue(dkimHeader, "bh=");
|
||||
String b64Signature = extractDkimValue(dkimHeader, "b=");
|
||||
|
||||
// Проверяем хэш тела
|
||||
if (b64BodyHash != null) {
|
||||
byte[] claimedBodyHash = Base64.getDecoder().decode(b64BodyHash);
|
||||
if (!MessageDigest.isEqual(bodyHash, claimedBodyHash)) {
|
||||
log.warn("DKIM: хэш тела не совпадает");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем подпись
|
||||
String headerCanon = headersToSign.toString();
|
||||
byte[] signingInput = (headerCanon + "bh=" + b64BodyHash + "\rbh=" + b64BodyHash).getBytes();
|
||||
|
||||
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(signingInput);
|
||||
|
||||
byte[] signatureBytes = Base64.getDecoder().decode(b64Signature);
|
||||
boolean valid = signature.verify(signatureBytes);
|
||||
|
||||
if (valid) {
|
||||
log.info("DKIM: подпись локального домена верифицирована");
|
||||
} else {
|
||||
log.warn("DKIM: подпись локального домена не верифицирована");
|
||||
}
|
||||
|
||||
return valid;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DKIM: ошибка верификации локальной подписи", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает значение параметра из DKIM-заголовка.
|
||||
*/
|
||||
private String extractDkimValue(String dkimHeader, String param) {
|
||||
int index = dkimHeader.indexOf(param);
|
||||
if (index == -1) return null;
|
||||
|
||||
int start = index + param.length();
|
||||
int end = dkimHeader.indexOf(";", start);
|
||||
if (end == -1) end = dkimHeader.length();
|
||||
|
||||
return dkimHeader.substring(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает заголовок из MIME-сообщения.
|
||||
*/
|
||||
private String extractHeader(MimeMessage mimeMessage, String name) throws MessagingException {
|
||||
String[] headers = mimeMessage.getHeader(name);
|
||||
if (headers == null || headers.length == 0) return "";
|
||||
return headers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает тело MIME-сообщения.
|
||||
*/
|
||||
private String extractBody(MimeMessage mimeMessage) throws MessagingException, IOException {
|
||||
Object content = mimeMessage.getContent();
|
||||
if (content instanceof String) {
|
||||
return (String) content;
|
||||
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
|
||||
jakarta.mail.internet.MimeMultipart multipart = (jakarta.mail.internet.MimeMultipart) content;
|
||||
StringBuilder body = new StringBuilder();
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
var part = multipart.getBodyPart(i);
|
||||
String contentType = part.getContentType();
|
||||
if (contentType != null && contentType.toLowerCase().contains("text")) {
|
||||
Object partContent = part.getContent();
|
||||
if (partContent instanceof String) {
|
||||
body.append(partContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Канонизация тела сообщения (relaxed канонизация).
|
||||
* Убирает лишние пробелы и переводы строк.
|
||||
*/
|
||||
private String canonicalizeBody(String body) {
|
||||
if (body == null) return "";
|
||||
// Relaxed: нормализуем CRLF в LF, убираем пробелы в конце строк
|
||||
return body.replaceAll("\\r\\n", "\n")
|
||||
.replaceAll("\\r", "\n")
|
||||
.replaceAll(" +\n", "\n")
|
||||
.replaceAll("\n+", "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет SHA-256 хэш тела сообщения.
|
||||
*/
|
||||
private byte[] hashBody(String body) throws NoSuchAlgorithmException {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return digest.digest(body.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает публичный ключ в формате DNS TXT-записи.
|
||||
* Используется для публикации в DNS.
|
||||
*/
|
||||
public String getPublicKeyForDns() {
|
||||
if (publicKeyBase64 == null) return null;
|
||||
return "v=DKIM1; k=rsa; p=" + publicKeyBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает домен DKIM.
|
||||
*/
|
||||
public String getDomain() {
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает селектор DKIM.
|
||||
*/
|
||||
public String getSelector() {
|
||||
return DKIM_SELECTOR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.delivery.DeliveryWorker;
|
||||
import com.mailserver.delivery.OutboundMessage;
|
||||
import jakarta.mail.BodyPart;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Multipart;
|
||||
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;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Stores a message in one of our mailboxes. Mail from outside is checked first — DKIM (signature),
|
||||
* SPF (the connecting IP against the envelope sender's domain), DMARC (policy of the From domain);
|
||||
* a failure marks it as spam. Mail from our own logged-in users is trusted.
|
||||
*/
|
||||
@Service
|
||||
public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LocalDelivery.class);
|
||||
|
||||
private final MailService mailService;
|
||||
private final DkimService dkimService;
|
||||
private final SpfService spfService;
|
||||
private final DmarcService dmarcService;
|
||||
private final String hostname;
|
||||
|
||||
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
|
||||
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||
this.mailService = mailService;
|
||||
this.dkimService = dkimService;
|
||||
this.spfService = spfService;
|
||||
this.dmarcService = dmarcService;
|
||||
this.hostname = hostname;
|
||||
}
|
||||
|
||||
/** Blocking: call from a worker thread. */
|
||||
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
|
||||
throws MessagingException, IOException {
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
|
||||
|
||||
String senderEmail = envelopeFrom;
|
||||
String senderName = null;
|
||||
if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) {
|
||||
senderEmail = from.getAddress();
|
||||
senderName = from.getPersonal();
|
||||
}
|
||||
String subject = mime.getSubject() == null ? "" : mime.getSubject();
|
||||
Body body = body(mime);
|
||||
|
||||
boolean spam = false;
|
||||
if (!trusted) {
|
||||
DkimService.Verification dkim = dkimService.verify(raw);
|
||||
String spfSender = envelopeFrom == null || envelopeFrom.isEmpty() ? senderEmail : envelopeFrom;
|
||||
SpfService.SpfResult spf = spfService.checkSpf(spfSender, remoteIp);
|
||||
boolean spfPassed = spf == SpfService.SpfResult.PASS;
|
||||
DmarcService.DmarcResult dmarc = dmarcService.checkDmarc(senderEmail, remoteIp, spfPassed, dkim.passed(), dkim.domain());
|
||||
spam = dmarcService.shouldMarkAsSpam(dmarc) || spf == SpfService.SpfResult.FAIL || (dkim.signed() && !dkim.passed());
|
||||
log.info("Incoming {} -> {} from {}: DKIM {}, SPF {}, DMARC {}{}", senderEmail, mailbox, remoteIp,
|
||||
dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : "");
|
||||
}
|
||||
|
||||
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<String> 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<FilePart> attachments(Part part) throws MessagingException, IOException {
|
||||
List<FilePart> files = new ArrayList<>();
|
||||
collect(part, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
private static void collect(Part part, List<FilePart> 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. */
|
||||
@Override
|
||||
public void bounce(OutboundMessage message, String reason) {
|
||||
String text = "Your message to " + message.getRcptTo() + " could not be delivered.\n\n"
|
||||
+ "Reason: " + reason + "\n\nAttempts: " + message.getAttempts() + "\n";
|
||||
try {
|
||||
mailService.receiveMessage("mailer-daemon@" + hostname, "Mail Delivery System", message.getMailFrom(),
|
||||
"Undelivered: message to " + message.getRcptTo(), text, false, false).block();
|
||||
} catch (IllegalArgumentException notOurs) {
|
||||
log.info("Bounce not stored: sender {} is not a local mailbox", message.getMailFrom());
|
||||
}
|
||||
}
|
||||
|
||||
record Body(String text, boolean html) {
|
||||
}
|
||||
|
||||
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
|
||||
static Body body(Part part) throws MessagingException, IOException {
|
||||
String html = find(part, "text/html");
|
||||
if (html != null) {
|
||||
return new Body(html, true);
|
||||
}
|
||||
String plain = find(part, "text/plain");
|
||||
return new Body(plain == null ? "" : plain, false);
|
||||
}
|
||||
|
||||
private static String find(Part part, String type) throws MessagingException, IOException {
|
||||
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) {
|
||||
return null;
|
||||
}
|
||||
if (part.isMimeType(type)) {
|
||||
Object content = part.getContent();
|
||||
if (content instanceof String s) {
|
||||
return s;
|
||||
}
|
||||
if (content instanceof InputStream in) {
|
||||
try (in) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) {
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
BodyPart child = multipart.getBodyPart(i);
|
||||
String found = find(child, type);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ 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.dto.SendRequest;
|
||||
import com.mailserver.repository.AttachmentRepository;
|
||||
import com.mailserver.repository.ContactRepository;
|
||||
import com.mailserver.repository.FolderRepository;
|
||||
@@ -15,16 +17,14 @@ import com.mailserver.repository.MessageRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
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.publisher.Sinks;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -33,11 +33,11 @@ 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;
|
||||
import reactor.netty.Connection;
|
||||
import reactor.netty.tcp.TcpClient;
|
||||
|
||||
@Service
|
||||
public class MailService {
|
||||
@@ -50,113 +50,245 @@ public class MailService {
|
||||
private final AttachmentRepository attachmentRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final SpamFilterService spamFilterService;
|
||||
private final DkimService dkimService;
|
||||
|
||||
@Value("${mailserver.attachments.dir:./attachments}")
|
||||
private String attachmentsDir;
|
||||
|
||||
@Value("${mailserver.smtp.port:3025}")
|
||||
private int smtpPort;
|
||||
|
||||
@Value("${mailserver.smtp.hostname:localhost}")
|
||||
private String smtpHostname;
|
||||
|
||||
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
||||
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
||||
ContactRepository contactRepository,
|
||||
SpamFilterService spamFilterService,
|
||||
DkimService dkimService) {
|
||||
SpamFilterService spamFilterService) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.folderRepository = folderRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.attachmentRepository = attachmentRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.spamFilterService = spamFilterService;
|
||||
this.dkimService = dkimService;
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> sendMessage(String fromEmail, SendRequest request) {
|
||||
return userRepository.findByEmail(fromEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Sender not found: " + fromEmail)))
|
||||
.flatMap(fromUser ->
|
||||
folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
||||
.flatMap(sentFolder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(sentFolder.getId());
|
||||
message.setSenderEmail(fromEmail);
|
||||
message.setSenderName(fromUser.getDisplayName());
|
||||
message.setRecipientEmail(request.getTo());
|
||||
message.setSubject(request.getSubject());
|
||||
message.setBody(request.getBody());
|
||||
message.setHtml(request.isHtml());
|
||||
message.setRead(true);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
message.setMessageSize((long) (request.getSubject().length() + request.getBody().length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.flatMap(message ->
|
||||
sendViaSmtp(fromEmail, request.getTo(), request.getSubject(), request.getBody(),
|
||||
request.isHtml(), message.getMessageId())
|
||||
.doOnSuccess(v -> log.info("Email sent via SMTP: {} -> {}", fromEmail, request.getTo()))
|
||||
.onErrorResume(e -> {
|
||||
log.error("SMTP send failed: {}", e.getMessage(), e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.thenReturn(message)
|
||||
)
|
||||
.map(this::toResponse)
|
||||
);
|
||||
/** System folders in the order the web client shows them; the rest — own folders, by name. */
|
||||
public static final List<String> 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<FolderResponse> 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)));
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
private static int folderOrder(FolderResponse folder) {
|
||||
int index = SYSTEM_FOLDERS.indexOf(folder.name());
|
||||
return folder.system() && index >= 0 ? index : SYSTEM_FOLDERS.size();
|
||||
}
|
||||
|
||||
public Mono<FolderResponse> 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<Void> 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<Void> 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<Integer> 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<Void> 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<Folder> 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<Folder> 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<Message> 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);
|
||||
message.setRead(true);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(messageId);
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Creates or updates a draft of the user (only a message in their Drafts folder can be updated). */
|
||||
public Mono<MessageResponse> saveDraft(User user, Long draftId, DraftRequest request) {
|
||||
Mono<Message> 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<Message> 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<Void> finishDraft(Long draftId, Long sentMessageId) {
|
||||
return attachmentRepository.moveAll(draftId, sentMessageId)
|
||||
.then(messageRepository.deleteById(draftId));
|
||||
}
|
||||
|
||||
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
String subject, String body, boolean isHtml) {
|
||||
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
public Mono<Message> 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<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.findByFolderId(folderId, pageable)
|
||||
.map(this::toResponse);
|
||||
/**
|
||||
* 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<Message> 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<MessageResponse> listMessages(Long folderId, Long userId, int limit) {
|
||||
int safeLimit = Math.max(1, Math.min(limit, LIST_LIMIT));
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.findLatest(folder.getId(), safeLimit))
|
||||
.concatMap(this::withAttachmentCount)
|
||||
.map(MailService::preview);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.searchInFolder(folderId, keyword, pageable)
|
||||
.map(this::toResponse);
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
|
||||
.take(LIST_LIMIT)
|
||||
.concatMap(this::withAttachmentCount)
|
||||
.map(MailService::preview);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
|
||||
@@ -164,25 +296,125 @@ public class MailService {
|
||||
String sender, String recipient,
|
||||
int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.searchWithFilters(folderId, subject, body, sender, recipient, pageable)
|
||||
.map(this::toResponse);
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
|
||||
.take(LIST_LIMIT)
|
||||
.concatMap(this::withAttachmentCount)
|
||||
.map(MailService::preview);
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.map(this::toResponse);
|
||||
return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount);
|
||||
}
|
||||
|
||||
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
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<MessageResponse> updateMessage(Long messageId, Long userId, MessageUpdateRequest update) {
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
message.setRead(true);
|
||||
return messageRepository.save(message).then();
|
||||
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<Void> deleteMessage(Long messageId, Long userId) {
|
||||
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<Void> 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<Long> 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. */
|
||||
private Mono<Folder> ownedFolder(Long folderId, Long userId) {
|
||||
return folderRepository.findById(folderId)
|
||||
.filter(folder -> folder.getUserId().equals(userId))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Folder not found")));
|
||||
}
|
||||
|
||||
private Mono<Message> ownedMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.filterWhen(message -> folderRepository.findById(message.getFolderId())
|
||||
.map(folder -> folder.getUserId().equals(userId))
|
||||
.defaultIfEmpty(false))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Message not found")));
|
||||
}
|
||||
|
||||
private Mono<Attachment> ownedAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.filterWhen(attachment -> ownedMessage(attachment.getMessageId(), userId).hasElement()
|
||||
.onErrorReturn(false))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found")));
|
||||
}
|
||||
|
||||
private Mono<MessageResponse> 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;
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
||||
return messageRepository.deleteById(messageId);
|
||||
private static MessageResponse preview(MessageResponse response) {
|
||||
String body = response.getBody();
|
||||
if (body == null) {
|
||||
return response;
|
||||
}
|
||||
String text = response.isHtml() ? body.replaceAll("(?s)<style.*?</style>|<[^>]+>", " ") : 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) {
|
||||
@@ -191,6 +423,7 @@ public class MailService {
|
||||
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());
|
||||
@@ -203,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<Attachment> draftAttachments(Long draftId, Long userId) {
|
||||
return ownedDraft(draftId, userId).flatMapMany(draft -> attachmentRepository.findByMessageId(draft.getId()));
|
||||
}
|
||||
|
||||
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
||||
String originalFilename, String contentType,
|
||||
long sizeBytes, String filePath) {
|
||||
return messageRepository.findById(messageId)
|
||||
return ownedDraft(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
Attachment attachment = new Attachment();
|
||||
attachment.setMessageId(messageId);
|
||||
@@ -223,31 +476,21 @@ public class MailService {
|
||||
return attachmentsDir;
|
||||
}
|
||||
|
||||
public String getAttachmentFilePath(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.map(Attachment::getFilePath)
|
||||
.block();
|
||||
}
|
||||
|
||||
public AttachmentResponse getAttachmentInfo(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.map(this::toAttachmentResponse)
|
||||
.block();
|
||||
/** The attachment record (file path, name, type) — only the owner's. */
|
||||
public Mono<Attachment> getAttachment(Long attachmentId, Long userId) {
|
||||
return ownedAttachment(attachmentId, userId);
|
||||
}
|
||||
|
||||
public Flux<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
|
||||
return attachmentRepository.findByMessageId(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMapMany(message -> attachmentRepository.findByMessageId(message.getId()))
|
||||
.map(this::toAttachmentResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
return ownedAttachment(attachmentId, userId)
|
||||
.flatMap(attachment -> {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
||||
} catch (IOException e) {
|
||||
// Игнорируем
|
||||
}
|
||||
deleteFile(attachment.getFilePath());
|
||||
return attachmentRepository.delete(attachment);
|
||||
});
|
||||
}
|
||||
@@ -261,132 +504,6 @@ public class MailService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private Mono<Void> sendViaSmtp(String from, String to, String subject, String body,
|
||||
boolean isHtml, String messageId) {
|
||||
TcpClient client = TcpClient.create()
|
||||
.host(smtpHostname)
|
||||
.port(smtpPort)
|
||||
.option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000)
|
||||
.option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
|
||||
|
||||
Properties props = new Properties();
|
||||
Session mailSession = Session.getDefaultInstance(props);
|
||||
MimeMessage mimeMessage = new MimeMessage(mailSession);
|
||||
try {
|
||||
mimeMessage.setFrom(from);
|
||||
mimeMessage.setRecipient(jakarta.mail.Message.RecipientType.TO, new jakarta.mail.internet.InternetAddress(to));
|
||||
mimeMessage.setSubject(subject);
|
||||
mimeMessage.setText(body, isHtml ? "text/html" : "text/plain", "UTF-8");
|
||||
mimeMessage.setHeader("Message-ID", "<" + messageId + "@" + smtpHostname + ">");
|
||||
mimeMessage.setSentDate(new java.util.Date());
|
||||
|
||||
dkimService.signMessage(mimeMessage);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
mimeMessage.writeTo(baos);
|
||||
String mimeContent = baos.toString(StandardCharsets.UTF_8);
|
||||
|
||||
StringBuilder dataBody = new StringBuilder();
|
||||
String[] lines = mimeContent.split("\\r?\\n");
|
||||
for (String mimeLine : lines) {
|
||||
if (mimeLine.startsWith(".")) {
|
||||
dataBody.append(".").append(mimeLine);
|
||||
} else {
|
||||
dataBody.append(mimeLine);
|
||||
}
|
||||
dataBody.append("\r\n");
|
||||
}
|
||||
dataBody.append(".\r\n");
|
||||
|
||||
// SMTP — последовательный протокол: команда -> ответ -> команда -> ответ
|
||||
// connection.inbound().receive() можно подписать только ОДИН раз,
|
||||
// поэтому используем Sinks.Many с directBestEffort для шаринга ответов
|
||||
return client.connect()
|
||||
.flatMap(connection -> {
|
||||
// Sinks.Many — один поток чтения, много подписчиков через multicast
|
||||
Sinks.Many<String> responseSink = Sinks.many().replay().limit(1);
|
||||
|
||||
// Подписываемся на inbound ОДИН раз и пишем все ответы в sink
|
||||
connection.inbound()
|
||||
.receive()
|
||||
.asString(StandardCharsets.UTF_8)
|
||||
.doOnNext(line -> {
|
||||
String trimmed = line.trim();
|
||||
log.debug("SMTP клиент ответ: {}", trimmed);
|
||||
responseSink.tryEmitNext(trimmed);
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
// Собираем все команды в список для последовательной отправки
|
||||
List<String> commands = new ArrayList<>();
|
||||
commands.add("EHLO " + smtpHostname + "\r\n");
|
||||
commands.add("MAIL FROM:<" + from + ">\r\n");
|
||||
commands.add("RCPT TO:<" + to + ">\r\n");
|
||||
commands.add("DATA\r\n");
|
||||
commands.add(dataBody.toString());
|
||||
commands.add("QUIT\r\n");
|
||||
|
||||
// Ожидаемые коды ответов для каждой команды (включая banner)
|
||||
List<String> expectedCodes = new ArrayList<>();
|
||||
expectedCodes.add("220"); // banner
|
||||
expectedCodes.add("250"); // EHLO
|
||||
expectedCodes.add("250"); // MAIL FROM
|
||||
expectedCodes.add("250"); // RCPT TO
|
||||
expectedCodes.add("354"); // DATA
|
||||
expectedCodes.add("250"); // тело DATA (завершение)
|
||||
expectedCodes.add("221"); // QUIT
|
||||
|
||||
// Последовательно отправляем команды и ждём ответы
|
||||
Mono<Void> send = Mono.empty();
|
||||
for (int i = 0; i < commands.size(); i++) {
|
||||
final String cmd = commands.get(i);
|
||||
final String expectedCode = expectedCodes.get(i);
|
||||
|
||||
send = send.then(sendAndAwait(connection, cmd, responseSink, expectedCode));
|
||||
}
|
||||
|
||||
return send.then(Mono.fromRunnable(() -> connection.dispose()));
|
||||
});
|
||||
|
||||
} catch (MessagingException | IOException e) {
|
||||
return Mono.error(new RuntimeException("Failed to prepare SMTP message", e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет SMTP команду и ждёт ответ с указанным кодом.
|
||||
* Для многострочных ответов (EHLO) читает до строки без '-' в конце кода.
|
||||
*/
|
||||
private Mono<Void> sendAndAwait(Connection connection, String command,
|
||||
Sinks.Many<String> responseSink,
|
||||
String expectedCode) {
|
||||
log.debug("SMTP клиент команда: {}", command.trim());
|
||||
|
||||
// Отправляем команду
|
||||
Mono<Void> send = connection.outbound()
|
||||
.sendString(Mono.just(command))
|
||||
.then();
|
||||
|
||||
// Ждём ответ из sink — каждый вызов получает свой Flux подписчика
|
||||
Mono<Void> readResponse = responseSink.asFlux()
|
||||
.takeWhile(line -> {
|
||||
if (expectedCode != null && line.startsWith(expectedCode + "-")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.doOnNext(line -> {
|
||||
if (expectedCode != null && !line.startsWith(expectedCode)) {
|
||||
log.warn("SMTP неожиданный ответ (ожидалось {}): {}", expectedCode, line);
|
||||
}
|
||||
})
|
||||
.then()
|
||||
.timeout(java.time.Duration.ofSeconds(30))
|
||||
.onErrorResume(e -> Mono.error(new RuntimeException("SMTP response timeout", e)));
|
||||
|
||||
return send.then(readResponse);
|
||||
}
|
||||
|
||||
// --- Contacts ---
|
||||
|
||||
public Flux<Contact> getContacts(Long userId) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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 (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;
|
||||
private final String hostname;
|
||||
|
||||
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||
this.users = users;
|
||||
this.mailService = mailService;
|
||||
this.router = router;
|
||||
this.hostname = hostname;
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
||||
List<String> to = addresses(request.getTo());
|
||||
List<String> 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 -> (draftId == null ? Flux.<Attachment>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.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
|
||||
.then(mailService.getMessage(sent.getId(), user.getId()))));
|
||||
}
|
||||
|
||||
private String submit(User user, List<String> to, List<String> cc, SendRequest request, List<Attachment> attachments)
|
||||
throws Exception {
|
||||
List<SmtpBackend.Recipient> 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<String> to, List<String> cc, SendRequest request, List<Attachment> 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<String> addresses(String raw) {
|
||||
Set<String> 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<String> concat(List<String> a, List<String> b) {
|
||||
List<String> all = new ArrayList<>(a);
|
||||
all.addAll(b);
|
||||
return all;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<FolderResponse> 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<Void> 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<User> updateDisplayName(User user, String displayName) {
|
||||
user.setDisplayName(displayName.trim());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.delivery.OutboundQueue;
|
||||
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import com.mailserver.service.DkimService;
|
||||
import com.mailserver.service.LocalDelivery;
|
||||
import com.mailserver.service.UserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Where mail goes — one rule for SMTP and for the web client:
|
||||
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
|
||||
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
|
||||
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
|
||||
*/
|
||||
@Service
|
||||
public class MailRouter implements SmtpBackend {
|
||||
|
||||
private final MailDomains domains;
|
||||
private final UserRepository users;
|
||||
private final UserService userService;
|
||||
private final LocalDelivery localDelivery;
|
||||
private final OutboundQueue queue;
|
||||
private final DkimService dkim;
|
||||
private final SmtpDeliveryClient client;
|
||||
|
||||
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
|
||||
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) {
|
||||
this.domains = domains;
|
||||
this.users = users;
|
||||
this.userService = userService;
|
||||
this.localDelivery = localDelivery;
|
||||
this.queue = queue;
|
||||
this.dkim = dkim;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean authenticate(String username, String password) {
|
||||
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block();
|
||||
return user != null && user.isActive() && userService.checkPassword(user, password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Route route(String recipient, String authenticatedUser) {
|
||||
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
|
||||
return Route.LOCAL;
|
||||
}
|
||||
if (domains.catchAll() != null) {
|
||||
return Route.LOCAL;
|
||||
}
|
||||
if (domains.isLocal(recipient)) {
|
||||
return Route.UNKNOWN_USER;
|
||||
}
|
||||
if (authenticatedUser == null) {
|
||||
return Route.RELAY_DENIED;
|
||||
}
|
||||
return client.mode() == SmtpDeliveryClient.Mode.OFF ? Route.OUTBOUND_DISABLED : Route.OUTBOUND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void accept(Envelope envelope, byte[] message) throws Exception {
|
||||
boolean trusted = envelope.authenticatedUser() != null;
|
||||
byte[] signed = null;
|
||||
for (Recipient recipient : envelope.recipients()) {
|
||||
switch (recipient.route()) {
|
||||
case LOCAL -> {
|
||||
String mailbox = mailbox(recipient.address());
|
||||
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
|
||||
}
|
||||
case OUTBOUND -> {
|
||||
if (signed == null) {
|
||||
signed = dkim.sign(message);
|
||||
}
|
||||
queue.enqueue(envelope.mailFrom(), recipient.address(), signed).block();
|
||||
}
|
||||
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */
|
||||
String mailbox(String address) {
|
||||
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
|
||||
return address;
|
||||
}
|
||||
return domains.catchAll();
|
||||
}
|
||||
|
||||
private boolean mailboxExists(String address) {
|
||||
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What the SMTP server asks the rest of the application. All methods may block (database, DNS):
|
||||
* the server calls them on a worker thread, never on the network event loop.
|
||||
*/
|
||||
public interface SmtpBackend {
|
||||
|
||||
/** Where a recipient goes, decided at RCPT TO. */
|
||||
enum Route {
|
||||
/** A mailbox of ours (or the catch-all mailbox) — store it. */
|
||||
LOCAL,
|
||||
/** Someone else's address, sender is logged in — queue for delivery. */
|
||||
OUTBOUND,
|
||||
/** Address of our domain that does not exist. */
|
||||
UNKNOWN_USER,
|
||||
/** Someone else's address and the sender is not logged in: we are not an open relay. */
|
||||
RELAY_DENIED,
|
||||
/** Someone else's address, outbound delivery is switched off on this server. */
|
||||
OUTBOUND_DISABLED
|
||||
}
|
||||
|
||||
record Recipient(String address, Route route) {
|
||||
}
|
||||
|
||||
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) {
|
||||
}
|
||||
|
||||
/** true — the address and password belong to an active mailbox. */
|
||||
boolean authenticate(String username, String password);
|
||||
|
||||
Route route(String recipient, String authenticatedUser);
|
||||
|
||||
/**
|
||||
* Stores or queues the message for every recipient. Returns normally only when the message is safe
|
||||
* (stored or queued) — then the client gets 250. Any exception → 451, the client will retry.
|
||||
*/
|
||||
void accept(Envelope envelope, byte[] message) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import io.netty.handler.codec.LineBasedFrameDecoder;
|
||||
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.tcp.TcpServer;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* SMTP server (RFC 5321) on Reactor Netty.
|
||||
* <ul>
|
||||
* <li>input is split into lines by {@link LineBasedFrameDecoder} — a network packet may hold half a line or many
|
||||
* pipelined commands, both work;</li>
|
||||
* <li>AUTH PLAIN / LOGIN against our mailboxes; someone else's address is accepted only from a logged-in sender
|
||||
* (no open relay); a logged-in sender may use only his own address in MAIL FROM;</li>
|
||||
* <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li>
|
||||
* <li>database and DNS work runs on a worker thread, not on the event loop.</li>
|
||||
* </ul>
|
||||
* No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network).
|
||||
*/
|
||||
public class SmtpServer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SmtpServer.class);
|
||||
|
||||
// RFC 5321 4.5.3.1.6: a text line is at most 1000 octets with CRLF; we allow more for sloppy clients
|
||||
static final int MAX_LINE = 64 * 1024;
|
||||
static final int MAX_RECIPIENTS = 100;
|
||||
private static final int MAX_BAD_COMMANDS = 20;
|
||||
|
||||
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
|
||||
Duration idleTimeout) {
|
||||
}
|
||||
|
||||
private final Settings settings;
|
||||
private final SmtpBackend backend;
|
||||
private DisposableServer server;
|
||||
|
||||
public SmtpServer(Settings settings, SmtpBackend backend) {
|
||||
this.settings = settings;
|
||||
this.backend = backend;
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server = TcpServer.create()
|
||||
.host(settings.host())
|
||||
.port(settings.port())
|
||||
.doOnConnection(connection -> connection
|
||||
.addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
|
||||
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false)))
|
||||
.handle((inbound, outbound) -> {
|
||||
String[] remoteIp = {"unknown"};
|
||||
inbound.withConnection(c -> {
|
||||
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
|
||||
remoteIp[0] = address.getAddress().getHostAddress();
|
||||
}
|
||||
});
|
||||
Session session = new Session(remoteIp[0]);
|
||||
Flux<String> replies = inbound.receive().asByteArray()
|
||||
.concatMap(session::onLine)
|
||||
.onErrorResume(e -> {
|
||||
log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString());
|
||||
return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n");
|
||||
})
|
||||
.takeUntil(reply -> session.closing);
|
||||
return outbound.sendString(Flux.concat(
|
||||
Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII);
|
||||
})
|
||||
.bindNow(Duration.ofSeconds(10));
|
||||
log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname());
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return server.port();
|
||||
}
|
||||
|
||||
public void stop() {
|
||||
if (server != null) {
|
||||
server.disposeNow(Duration.ofSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
private enum State { COMMAND, DATA, AUTH_PLAIN, AUTH_LOGIN_USER, AUTH_LOGIN_PASSWORD }
|
||||
|
||||
private final class Session {
|
||||
final String remoteIp;
|
||||
State state = State.COMMAND;
|
||||
String helo;
|
||||
boolean extended;
|
||||
String authenticatedUser;
|
||||
String pendingAuthUser;
|
||||
String mailFrom;
|
||||
final List<SmtpBackend.Recipient> recipients = new ArrayList<>();
|
||||
final ByteArrayOutputStream data = new ByteArrayOutputStream();
|
||||
boolean dataTooBig;
|
||||
int badCommands;
|
||||
volatile boolean closing;
|
||||
|
||||
Session(String remoteIp) {
|
||||
this.remoteIp = remoteIp;
|
||||
}
|
||||
|
||||
Mono<String> onLine(byte[] raw) {
|
||||
byte[] line = stripCr(raw);
|
||||
return switch (state) {
|
||||
case DATA -> onDataLine(line);
|
||||
case AUTH_PLAIN -> authPlain(ascii(line));
|
||||
case AUTH_LOGIN_USER -> authLoginUser(ascii(line));
|
||||
case AUTH_LOGIN_PASSWORD -> authLoginPassword(ascii(line));
|
||||
case COMMAND -> onCommand(ascii(line));
|
||||
};
|
||||
}
|
||||
|
||||
private Mono<String> onCommand(String line) {
|
||||
String[] parts = line.trim().split("\\s+", 2);
|
||||
String verb = parts[0].toUpperCase(Locale.ROOT);
|
||||
String arg = parts.length > 1 ? parts[1].trim() : "";
|
||||
return switch (verb) {
|
||||
case "EHLO", "HELO" -> hello(verb.equals("EHLO"), arg);
|
||||
case "AUTH" -> auth(arg);
|
||||
case "MAIL" -> mail(arg);
|
||||
case "RCPT" -> rcpt(arg);
|
||||
case "DATA" -> dataStart();
|
||||
case "RSET" -> {
|
||||
resetTransaction();
|
||||
yield reply("250 2.0.0 OK");
|
||||
}
|
||||
case "NOOP" -> reply("250 2.0.0 OK");
|
||||
case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message");
|
||||
case "STARTTLS" -> reply("454 4.7.0 TLS not available");
|
||||
case "QUIT" -> {
|
||||
closing = true;
|
||||
yield reply("221 2.0.0 " + settings.hostname() + " Bye");
|
||||
}
|
||||
case "" -> reply("500 5.5.2 Empty command");
|
||||
default -> bad("500 5.5.1 Command not recognized");
|
||||
};
|
||||
}
|
||||
|
||||
private Mono<String> hello(boolean ehlo, String arg) {
|
||||
if (arg.isEmpty()) {
|
||||
return reply("501 5.5.4 Hostname required");
|
||||
}
|
||||
helo = arg;
|
||||
extended = ehlo;
|
||||
resetTransaction();
|
||||
if (!ehlo) {
|
||||
return reply("250 " + settings.hostname());
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("250-").append(settings.hostname()).append(" Hello ").append(arg).append("\r\n");
|
||||
sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n");
|
||||
sb.append("250-8BITMIME\r\n");
|
||||
sb.append("250-PIPELINING\r\n");
|
||||
if (settings.authWithoutTls()) {
|
||||
sb.append("250-AUTH PLAIN LOGIN\r\n");
|
||||
}
|
||||
sb.append("250 ENHANCEDSTATUSCODES\r\n");
|
||||
return Mono.just(sb.toString());
|
||||
}
|
||||
|
||||
private Mono<String> auth(String arg) {
|
||||
if (!settings.authWithoutTls()) {
|
||||
return reply("538 5.7.11 Encryption required for requested authentication mechanism");
|
||||
}
|
||||
if (helo == null || !extended) {
|
||||
return reply("503 5.5.1 EHLO first");
|
||||
}
|
||||
if (authenticatedUser != null) {
|
||||
return reply("503 5.5.1 Already authenticated");
|
||||
}
|
||||
if (mailFrom != null) {
|
||||
return reply("503 5.5.1 AUTH not allowed during a mail transaction");
|
||||
}
|
||||
String[] parts = arg.split("\\s+", 2);
|
||||
String mechanism = parts[0].toUpperCase(Locale.ROOT);
|
||||
String initial = parts.length > 1 ? parts[1] : null;
|
||||
if (mechanism.equals("PLAIN")) {
|
||||
if (initial == null) {
|
||||
state = State.AUTH_PLAIN;
|
||||
return reply("334 ");
|
||||
}
|
||||
return authPlain(initial);
|
||||
}
|
||||
if (mechanism.equals("LOGIN")) {
|
||||
if (initial != null) {
|
||||
return authLoginUser(initial);
|
||||
}
|
||||
state = State.AUTH_LOGIN_USER;
|
||||
return reply("334 " + b64("Username:"));
|
||||
}
|
||||
return reply("504 5.5.4 Unrecognized authentication type");
|
||||
}
|
||||
|
||||
private Mono<String> authPlain(String response) {
|
||||
state = State.COMMAND;
|
||||
if (response.equals("*")) {
|
||||
return reply("501 5.0.0 Authentication cancelled");
|
||||
}
|
||||
String decoded = unb64(response);
|
||||
// authzid \0 authcid \0 password
|
||||
String[] fields = decoded == null ? new String[0] : decoded.split("\0", -1);
|
||||
if (fields.length != 3) {
|
||||
return reply("501 5.5.2 Cannot decode AUTH PLAIN response");
|
||||
}
|
||||
return checkPassword(fields[1], fields[2]);
|
||||
}
|
||||
|
||||
private Mono<String> authLoginUser(String response) {
|
||||
if (response.equals("*")) {
|
||||
state = State.COMMAND;
|
||||
return reply("501 5.0.0 Authentication cancelled");
|
||||
}
|
||||
pendingAuthUser = unb64(response);
|
||||
if (pendingAuthUser == null) {
|
||||
state = State.COMMAND;
|
||||
return reply("501 5.5.2 Cannot decode username");
|
||||
}
|
||||
state = State.AUTH_LOGIN_PASSWORD;
|
||||
return reply("334 " + b64("Password:"));
|
||||
}
|
||||
|
||||
private Mono<String> authLoginPassword(String response) {
|
||||
state = State.COMMAND;
|
||||
String user = pendingAuthUser;
|
||||
pendingAuthUser = null;
|
||||
if (response.equals("*")) {
|
||||
return reply("501 5.0.0 Authentication cancelled");
|
||||
}
|
||||
String password = unb64(response);
|
||||
if (password == null) {
|
||||
return reply("501 5.5.2 Cannot decode password");
|
||||
}
|
||||
return checkPassword(user, password);
|
||||
}
|
||||
|
||||
private Mono<String> checkPassword(String user, String password) {
|
||||
String username = user.trim().toLowerCase(Locale.ROOT);
|
||||
return blocking(() -> backend.authenticate(username, password))
|
||||
.map(ok -> {
|
||||
if (ok) {
|
||||
authenticatedUser = username;
|
||||
log.info("SMTP {}: authenticated as {}", remoteIp, username);
|
||||
return "235 2.7.0 Authentication successful\r\n";
|
||||
}
|
||||
log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
|
||||
badCommands++;
|
||||
return "535 5.7.8 Authentication credentials invalid\r\n";
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> mail(String arg) {
|
||||
if (helo == null) {
|
||||
return reply("503 5.5.1 EHLO/HELO first");
|
||||
}
|
||||
if (mailFrom != null) {
|
||||
return reply("503 5.5.1 Sender already specified");
|
||||
}
|
||||
if (!arg.regionMatches(true, 0, "FROM:", 0, 5)) {
|
||||
return reply("501 5.5.4 Syntax: MAIL FROM:<address>");
|
||||
}
|
||||
String rest = arg.substring(5).trim();
|
||||
String address = path(rest);
|
||||
if (address == null) {
|
||||
return reply("501 5.1.7 Bad sender address syntax");
|
||||
}
|
||||
Long size = sizeParam(rest);
|
||||
if (size != null && size > settings.maxMessageBytes()) {
|
||||
return reply("552 5.3.4 Message size exceeds fixed limit");
|
||||
}
|
||||
if (authenticatedUser != null && !address.isEmpty() && !address.equals(authenticatedUser)) {
|
||||
return reply("553 5.7.1 Sender address not owned by authenticated user");
|
||||
}
|
||||
mailFrom = address;
|
||||
return reply("250 2.1.0 Sender OK");
|
||||
}
|
||||
|
||||
private Mono<String> rcpt(String arg) {
|
||||
if (mailFrom == null) {
|
||||
return reply("503 5.5.1 MAIL first");
|
||||
}
|
||||
if (!arg.regionMatches(true, 0, "TO:", 0, 3)) {
|
||||
return reply("501 5.5.4 Syntax: RCPT TO:<address>");
|
||||
}
|
||||
String address = path(arg.substring(3).trim());
|
||||
if (address == null || address.isEmpty() || address.indexOf('@') < 1) {
|
||||
return reply("501 5.1.3 Bad recipient address syntax");
|
||||
}
|
||||
if (recipients.size() >= MAX_RECIPIENTS) {
|
||||
return reply("452 4.5.3 Too many recipients");
|
||||
}
|
||||
String user = authenticatedUser;
|
||||
return blocking(() -> backend.route(address, user))
|
||||
.map(route -> switch (route) {
|
||||
case LOCAL, OUTBOUND -> {
|
||||
recipients.add(new SmtpBackend.Recipient(address, route));
|
||||
yield "250 2.1.5 Recipient OK\r\n";
|
||||
}
|
||||
case UNKNOWN_USER -> "550 5.1.1 No such user here\r\n";
|
||||
case RELAY_DENIED -> {
|
||||
log.warn("SMTP {}: relay denied {} -> {}", remoteIp, mailFrom, address);
|
||||
yield "550 5.7.1 Relaying denied: authentication required\r\n";
|
||||
}
|
||||
case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n";
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> dataStart() {
|
||||
if (mailFrom == null) {
|
||||
return reply("503 5.5.1 MAIL first");
|
||||
}
|
||||
if (recipients.isEmpty()) {
|
||||
return reply("554 5.5.1 No valid recipients");
|
||||
}
|
||||
state = State.DATA;
|
||||
data.reset();
|
||||
dataTooBig = false;
|
||||
return reply("354 End data with <CR><LF>.<CR><LF>");
|
||||
}
|
||||
|
||||
private Mono<String> onDataLine(byte[] line) {
|
||||
if (line.length == 1 && line[0] == '.') {
|
||||
state = State.COMMAND;
|
||||
if (dataTooBig) {
|
||||
resetTransaction();
|
||||
return reply("552 5.3.4 Message size exceeds fixed limit");
|
||||
}
|
||||
SmtpBackend.Envelope envelope = new SmtpBackend.Envelope(
|
||||
remoteIp, helo, mailFrom, authenticatedUser, List.copyOf(recipients));
|
||||
byte[] message = withReceivedHeader(data.toByteArray());
|
||||
resetTransaction();
|
||||
return blocking(() -> {
|
||||
backend.accept(envelope, message);
|
||||
return true;
|
||||
})
|
||||
.map(ok -> "250 2.0.0 Message accepted\r\n")
|
||||
.onErrorResume(e -> {
|
||||
log.error("SMTP {}: message from {} not stored: {}", remoteIp, envelope.mailFrom(), e.toString());
|
||||
return Mono.just("451 4.3.0 Message not stored, try again later\r\n");
|
||||
});
|
||||
}
|
||||
if (!dataTooBig) {
|
||||
int offset = line.length > 0 && line[0] == '.' ? 1 : 0; // dot-unstuffing (RFC 5321 4.5.2)
|
||||
data.write(line, offset, line.length - offset);
|
||||
data.write('\r');
|
||||
data.write('\n');
|
||||
if (data.size() > settings.maxMessageBytes()) {
|
||||
dataTooBig = true;
|
||||
data.reset();
|
||||
}
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private byte[] withReceivedHeader(byte[] message) {
|
||||
String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n"
|
||||
+ "\tby " + settings.hostname() + " with " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\r\n"
|
||||
+ "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n";
|
||||
byte[] header = received.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] all = new byte[header.length + message.length];
|
||||
System.arraycopy(header, 0, all, 0, header.length);
|
||||
System.arraycopy(message, 0, all, header.length, message.length);
|
||||
return all;
|
||||
}
|
||||
|
||||
private void resetTransaction() {
|
||||
mailFrom = null;
|
||||
recipients.clear();
|
||||
data.reset();
|
||||
dataTooBig = false;
|
||||
}
|
||||
|
||||
private Mono<String> bad(String text) {
|
||||
if (++badCommands > MAX_BAD_COMMANDS) {
|
||||
closing = true;
|
||||
return reply("421 4.7.0 Too many errors, closing connection");
|
||||
}
|
||||
return reply(text);
|
||||
}
|
||||
}
|
||||
|
||||
private static Mono<String> reply(String line) {
|
||||
return Mono.just(line + "\r\n");
|
||||
}
|
||||
|
||||
private static <T> Mono<T> blocking(java.util.concurrent.Callable<T> work) {
|
||||
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic());
|
||||
}
|
||||
|
||||
/** "<a@b> SIZE=1" → "a@b"; "<>" → "" (null sender of bounces); malformed → null. */
|
||||
static String path(String text) {
|
||||
int start = text.indexOf('<');
|
||||
int end = text.indexOf('>', start + 1);
|
||||
if (start != 0 || end < 0) {
|
||||
return null;
|
||||
}
|
||||
String address = text.substring(1, end).trim();
|
||||
int colon = address.indexOf(':'); // source route "@a,@b:user@host" (RFC 5321 4.1.2) — ignored
|
||||
if (address.startsWith("@") && colon > 0) {
|
||||
address = address.substring(colon + 1);
|
||||
}
|
||||
if (address.contains(" ") || address.contains("<")) {
|
||||
return null;
|
||||
}
|
||||
return address.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static Long sizeParam(String text) {
|
||||
for (String param : text.substring(text.indexOf('>') + 1).trim().split("\\s+")) {
|
||||
if (param.regionMatches(true, 0, "SIZE=", 0, 5)) {
|
||||
try {
|
||||
return Long.parseLong(param.substring(5));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[] stripCr(byte[] line) {
|
||||
if (line.length > 0 && line[line.length - 1] == '\r') {
|
||||
byte[] copy = new byte[line.length - 1];
|
||||
System.arraycopy(line, 0, copy, 0, copy.length);
|
||||
return copy;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
private static String ascii(byte[] line) {
|
||||
return new String(line, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String b64(String text) {
|
||||
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String unb64(String text) {
|
||||
try {
|
||||
return new String(Base64.getDecoder().decode(text.trim()), StandardCharsets.UTF_8);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Local development only (./gradlew bootRun --args='--spring.profiles.active=dev'): Postgres from docker-compose.yml
|
||||
DB_PORT=5433
|
||||
DB_PASSWORD=secret
|
||||
JWT_SECRET=dev-only-jwt-secret-not-for-any-real-server-0123456789
|
||||
mailserver.domains=mailserver.local
|
||||
mailserver.admin.email=admin@mailserver.local
|
||||
mailserver.admin.password=admin123
|
||||
mailserver.registration.enabled=true
|
||||
mailserver.cors.origins=http://localhost:4200
|
||||
mailserver.smtp.auth-without-tls=true
|
||||
mailserver.dkim.domain=mailserver.local
|
||||
mailserver.spf.enabled=false
|
||||
mailserver.dmarc.enabled=false
|
||||
logging.level.com.mailserver=DEBUG
|
||||
@@ -1,54 +0,0 @@
|
||||
spring.application.name=mail-server
|
||||
|
||||
server.port=8080
|
||||
|
||||
# R2DBC — реактивный доступ к PostgreSQL
|
||||
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mailserver
|
||||
spring.r2dbc.username=mail
|
||||
spring.r2dbc.password=secret
|
||||
|
||||
# Flyway — миграции базы данных
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.url=jdbc:postgresql://localhost:5432/mailserver
|
||||
spring.flyway.user=mail
|
||||
spring.flyway.password=secret
|
||||
spring.flyway.locations=classpath:db/migration
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
|
||||
# Вложения
|
||||
mailserver.attachments.dir=./attachments
|
||||
spring.webflux.multipart.max-file-size=10MB
|
||||
spring.webflux.multipart.max-request-size=10MB
|
||||
|
||||
# SMTP-сервер для приёма входящих писем
|
||||
mailserver.smtp.port=3025
|
||||
mailserver.smtp.hostname=your-domain.com
|
||||
|
||||
# IMAP-сервер (отключён)
|
||||
mailserver.imap.enabled=false
|
||||
mailserver.imap.port=1143
|
||||
mailserver.imap.hostname=localhost
|
||||
|
||||
# DKIM — криптографическая подпись писем
|
||||
mailserver.dkim.enabled=false
|
||||
mailserver.dkim.domain=your-domain.com
|
||||
mailserver.dkim.keys-dir=./dkim-keys
|
||||
|
||||
# SPF — проверка отправителей через DNS
|
||||
mailserver.spf.enabled=false
|
||||
mailserver.spf.local-bypass=true
|
||||
|
||||
# DMARC — политика обработки писем, не прошедших SPF/DKIM
|
||||
mailserver.dmarc.enabled=false
|
||||
mailserver.dmarc.local-bypass=true
|
||||
|
||||
# SMTP релей — доставка писем на внешнюю почту
|
||||
# Порт 25 — стандартный SMTP relay между серверами (VPS)
|
||||
mailserver.relay.enabled=true
|
||||
mailserver.relay.port=25
|
||||
mailserver.relay.starttls=false
|
||||
mailserver.relay.auth-enabled=false
|
||||
mailserver.relay.username=
|
||||
mailserver.relay.password=
|
||||
|
||||
logging.level.com.mailserver=INFO
|
||||
@@ -1,54 +1,76 @@
|
||||
spring.application.name=mail-server
|
||||
|
||||
server.port=8080
|
||||
# behind a proxy (Jambotron Caddy): take scheme/host from X-Forwarded-*, otherwise the browser's own
|
||||
# domain looks like another origin to CORS and every API call with an Origin header gets 403
|
||||
server.forward-headers-strategy=framework
|
||||
|
||||
# R2DBC — реактивный доступ к PostgreSQL
|
||||
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mailserver
|
||||
spring.r2dbc.username=mail
|
||||
spring.r2dbc.password=secret
|
||||
# Secrets: environment variables, or files in a config tree (file name = variable name, e.g. /run/mail-secrets/DB_PASSWORD).
|
||||
# No defaults — the server does not start without them (dev profile: application-dev.properties).
|
||||
spring.config.import=optional:configtree:${MAILSERVER_SECRETS_DIR:/run/mail-secrets}/
|
||||
|
||||
# Flyway — миграции базы данных
|
||||
# PostgreSQL: R2DBC for the app, JDBC for Flyway migrations
|
||||
spring.r2dbc.url=r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver}
|
||||
spring.r2dbc.username=${DB_USER:mail}
|
||||
spring.r2dbc.password=${DB_PASSWORD}
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.url=jdbc:postgresql://localhost:5432/mailserver
|
||||
spring.flyway.user=mail
|
||||
spring.flyway.password=secret
|
||||
spring.flyway.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver}
|
||||
spring.flyway.user=${DB_USER:mail}
|
||||
spring.flyway.password=${DB_PASSWORD}
|
||||
spring.flyway.locations=classpath:db/migration
|
||||
spring.flyway.baseline-on-migrate=true
|
||||
|
||||
# Вложения
|
||||
mailserver.attachments.dir=./attachments
|
||||
jwt.secret=${JWT_SECRET}
|
||||
|
||||
# Mailboxes of these domains live here; catch-all: mail to any address without its own mailbox here goes into this one mailbox (mail trap), nothing goes outside
|
||||
mailserver.domains=${MAIL_DOMAINS:localhost}
|
||||
mailserver.catch-all=${MAIL_CATCH_ALL:}
|
||||
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
||||
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
||||
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
|
||||
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
|
||||
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
||||
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||
|
||||
# Attachments
|
||||
mailserver.attachments.dir=${MAIL_ATTACHMENTS_DIR:./attachments}
|
||||
spring.webflux.multipart.max-file-size=10MB
|
||||
spring.webflux.multipart.max-request-size=10MB
|
||||
|
||||
# SMTP-сервер для приёма входящих писем
|
||||
mailserver.smtp.port=3025
|
||||
mailserver.smtp.hostname=localhost
|
||||
# SMTP server (incoming mail and submission). AUTH without TLS only in a closed network (docker network, VM)
|
||||
mailserver.smtp.port=${SMTP_PORT:3025}
|
||||
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
|
||||
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
|
||||
mailserver.smtp.max-message-bytes=26214400
|
||||
|
||||
# IMAP-сервер для доступа к письмам
|
||||
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
||||
mailserver.outbound.mode=${OUTBOUND_MODE:off}
|
||||
mailserver.outbound.host=${OUTBOUND_HOST:}
|
||||
mailserver.outbound.port=${OUTBOUND_PORT:587}
|
||||
mailserver.outbound.username=${OUTBOUND_USERNAME:}
|
||||
mailserver.outbound.password=${OUTBOUND_PASSWORD:}
|
||||
mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
|
||||
|
||||
# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2)
|
||||
mailserver.imap.enabled=false
|
||||
mailserver.imap.port=1143
|
||||
mailserver.imap.hostname=localhost
|
||||
|
||||
# DKIM — криптографическая подпись писем
|
||||
mailserver.dkim.enabled=false
|
||||
mailserver.dkim.domain=localhost
|
||||
mailserver.dkim.keys-dir=./dkim-keys
|
||||
# DKIM — signing of our mail; DNS record printed to the log at start
|
||||
mailserver.dkim.enabled=${DKIM_ENABLED:true}
|
||||
mailserver.dkim.domain=${DKIM_DOMAIN:localhost}
|
||||
mailserver.dkim.selector=${DKIM_SELECTOR:mail}
|
||||
mailserver.dkim.keys-dir=${DKIM_KEYS_DIR:./dkim-keys}
|
||||
|
||||
# SPF — проверка отправителей через DNS
|
||||
mailserver.spf.enabled=false
|
||||
# SPF / DMARC for incoming mail
|
||||
mailserver.spf.enabled=${SPF_ENABLED:true}
|
||||
mailserver.spf.local-bypass=true
|
||||
|
||||
# DMARC — политика обработки писем, не прошедших SPF/DKIM
|
||||
mailserver.dmarc.enabled=false
|
||||
mailserver.dmarc.enabled=${DMARC_ENABLED:true}
|
||||
mailserver.dmarc.local-bypass=true
|
||||
|
||||
# SMTP релей — доставка писем на внешнюю почту
|
||||
# Порт 587 — submission с STARTTLS (для отправки через SMTP-сервер)
|
||||
mailserver.relay.enabled=true
|
||||
mailserver.relay.port=587
|
||||
mailserver.relay.starttls=true
|
||||
mailserver.relay.auth-enabled=false
|
||||
mailserver.relay.username=
|
||||
mailserver.relay.password=
|
||||
# error responses carry the reason ("No such mailbox", "Sending outside is disabled") for the web client
|
||||
server.error.include-message=always
|
||||
|
||||
logging.level.com.mailserver=DEBUG
|
||||
management.endpoints.web.exposure.include=health
|
||||
management.endpoint.health.show-details=never
|
||||
|
||||
logging.level.com.mailserver=INFO
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Очередь отправки наружу: письмо хранится как готовые байты (уже с DKIM-подписью) до доставки
|
||||
CREATE TABLE outbound_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mail_from VARCHAR(255) NOT NULL,
|
||||
rcpt_to VARCHAR(255) NOT NULL,
|
||||
message BYTEA NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_error VARCHAR(1000),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_outbound_queue_due ON outbound_queue(next_attempt_at) WHERE status = 'PENDING';
|
||||
@@ -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);
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DataInitializerTest {
|
||||
|
||||
@Test
|
||||
void generatedPasswordsAreAppendedToAFileOnlyTheOwnerReads(@TempDir Path dir) throws Exception {
|
||||
Path file = dir.resolve("data/initial-passwords.txt");
|
||||
|
||||
DataInitializer.appendPassword(file, "admin@mail.test", "secret-1");
|
||||
DataInitializer.appendPassword(file, "trap@mail.test", "secret-2");
|
||||
|
||||
assertThat(Files.readAllLines(file)).hasSize(2);
|
||||
assertThat(Files.readAllLines(file).get(0)).endsWith(" admin@mail.test secret-1");
|
||||
assertThat(Files.readAllLines(file).get(1)).endsWith(" trap@mail.test secret-2");
|
||||
assertThat(PosixFilePermissions.toString(Files.getPosixFilePermissions(file))).isEqualTo("rw-------");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MailControllerTest {
|
||||
|
||||
@Test
|
||||
void attachmentNameCannotLeaveTheFolder() {
|
||||
assertThat(MailController.safeFilename("../../etc/passwd")).isEqualTo("passwd");
|
||||
assertThat(MailController.safeFilename("..\\..\\boot.ini")).isEqualTo("boot.ini");
|
||||
assertThat(MailController.safeFilename("..")).isEqualTo("file");
|
||||
assertThat(MailController.safeFilename("a\"b\r\n.txt")).isEqualTo("ab.txt");
|
||||
assertThat(MailController.safeFilename(null)).isEqualTo("file");
|
||||
assertThat(MailController.safeFilename("report 2026.pdf")).isEqualTo("report 2026.pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DeliveryWorkerTest {
|
||||
|
||||
private OutboundRepository repository;
|
||||
private final List<String> bounces = new ArrayList<>();
|
||||
private SmtpDeliveryClient.Result next;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(OutboundRepository.class);
|
||||
when(repository.save(any())).thenAnswer(call -> Mono.just(call.getArgument(0)));
|
||||
}
|
||||
|
||||
private DeliveryWorker worker() {
|
||||
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX,
|
||||
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx", 1000, 25), domain -> List.of("mx")) {
|
||||
@Override
|
||||
public Result deliver(String from, String recipient, byte[] message) {
|
||||
return next;
|
||||
}
|
||||
};
|
||||
return new DeliveryWorker(repository, client, (message, reason) -> bounces.add(message.getRcptTo() + ": " + reason));
|
||||
}
|
||||
|
||||
private static OutboundMessage queued() {
|
||||
OutboundMessage message = new OutboundMessage();
|
||||
message.setId(1L);
|
||||
message.setMailFrom("alice@ours.test");
|
||||
message.setRcptTo("bob@theirs.test");
|
||||
message.setMessage(new byte[]{1});
|
||||
return message;
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveredIsDone() {
|
||||
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.DELIVERED, "mx");
|
||||
OutboundMessage message = queued();
|
||||
worker().attempt(message, Instant.EPOCH);
|
||||
assertThat(message.getStatus()).isEqualTo(OutboundMessage.DELIVERED);
|
||||
assertThat(message.getLastError()).isNull();
|
||||
assertThat(bounces).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void temporaryFailureIsRetriedWithGrowingPausesThenBounced() {
|
||||
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.TEMPORARY, "451 greylisted");
|
||||
OutboundMessage message = queued();
|
||||
DeliveryWorker worker = worker();
|
||||
Instant now = Instant.EPOCH;
|
||||
List<Duration> pauses = new ArrayList<>();
|
||||
for (int i = 0; i < DeliveryWorker.RETRY_PAUSES.size(); i++) {
|
||||
worker.attempt(message, now);
|
||||
assertThat(message.getStatus()).isEqualTo(OutboundMessage.PENDING);
|
||||
pauses.add(Duration.between(now, message.getNextAttemptAt()));
|
||||
now = message.getNextAttemptAt();
|
||||
}
|
||||
assertThat(pauses).isEqualTo(DeliveryWorker.RETRY_PAUSES);
|
||||
|
||||
worker.attempt(message, now);
|
||||
assertThat(message.getStatus()).isEqualTo(OutboundMessage.FAILED);
|
||||
assertThat(bounces).singleElement().asString().contains("gave up after 8 attempts").contains("451 greylisted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permanentFailureBouncesAtOnce() {
|
||||
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.PERMANENT, "550 no such user");
|
||||
OutboundMessage message = queued();
|
||||
worker().attempt(message, Instant.EPOCH);
|
||||
assertThat(message.getStatus()).isEqualTo(OutboundMessage.FAILED);
|
||||
assertThat(message.getLastError()).isEqualTo("550 no such user");
|
||||
assertThat(bounces).containsExactly("bob@theirs.test: 550 no such user");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.mailserver.delivery;
|
||||
|
||||
import com.icegreen.greenmail.util.GreenMail;
|
||||
import com.icegreen.greenmail.util.ServerSetup;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SmtpDeliveryClientTest {
|
||||
|
||||
private static final String MESSAGE = "From: alice@ours.test\r\nTo: bob@theirs.test\r\nSubject: Hi\r\n"
|
||||
+ "DKIM-Signature: v=1; a=rsa-sha256; d=ours.test; s=mail; h=from; bh=x; b=y\r\n\r\nbody\r\n.dot line\r\n";
|
||||
|
||||
private GreenMail greenMail;
|
||||
|
||||
@BeforeEach
|
||||
void start() {
|
||||
greenMail = new GreenMail(new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP));
|
||||
greenMail.setUser("relay-user", "relay-pass");
|
||||
greenMail.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
greenMail.stop();
|
||||
}
|
||||
|
||||
private int port() {
|
||||
return greenMail.getSmtp().getPort();
|
||||
}
|
||||
|
||||
private SmtpDeliveryClient mxClient(int mxPort, List<String> hosts) {
|
||||
return new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX, "", 0, "", "",
|
||||
SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, mxPort), domain -> hosts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliversToTheMxAndKeepsTheBytesUnchanged() throws Exception {
|
||||
SmtpDeliveryClient.Result result = mxClient(port(), List.of("127.0.0.1"))
|
||||
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThat(result.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
|
||||
MimeMessage[] received = greenMail.getReceivedMessages();
|
||||
assertThat(received).hasSize(1);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
received[0].writeTo(out);
|
||||
// GreenMail adds its Received/Return-Path; everything we sent is there byte for byte (DKIM stays valid)
|
||||
assertThat(out.toString(StandardCharsets.UTF_8)).contains(MESSAGE.stripTrailing());
|
||||
}
|
||||
|
||||
@Test
|
||||
void triesTheNextMxWhenTheFirstDoesNotAnswer() throws Exception {
|
||||
int closed;
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
closed = socket.getLocalPort();
|
||||
}
|
||||
// first "host" refuses the connection, second is GreenMail — both on 127.0.0.1, so use two clients' ports:
|
||||
SmtpDeliveryClient.Result refused = mxClient(closed, List.of("127.0.0.1"))
|
||||
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(refused.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.TEMPORARY);
|
||||
|
||||
SmtpDeliveryClient.Result fallback = mxClient(port(), List.of("unreachable.invalid", "127.0.0.1"))
|
||||
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(fallback.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainWithoutMailServerIsAPermanentFailure() {
|
||||
SmtpDeliveryClient.Result result = mxClient(port(), List.of())
|
||||
.deliver("alice@ours.test", "bob@nowhere.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(result.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.PERMANENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dnsTroubleIsTemporary() {
|
||||
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX,
|
||||
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, port()),
|
||||
domain -> { throw new SmtpDeliveryClient.TemporaryDnsException("SERVFAIL"); });
|
||||
assertThat(client.deliver("a@ours.test", "b@theirs.test", MESSAGE.getBytes()).outcome())
|
||||
.isEqualTo(SmtpDeliveryClient.Outcome.TEMPORARY);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsThroughASmarthostWithLogin() {
|
||||
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.SMARTHOST,
|
||||
"127.0.0.1", port(), "relay-user", "relay-pass", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, 25));
|
||||
|
||||
assertThat(client.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes()).outcome())
|
||||
.isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
|
||||
assertThat(greenMail.getReceivedMessages()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offModeSendsNothing() {
|
||||
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.OFF,
|
||||
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, 25));
|
||||
assertThat(client.deliver("a@ours.test", "b@theirs.test", MESSAGE.getBytes()).outcome())
|
||||
.isEqualTo(SmtpDeliveryClient.Outcome.PERMANENT);
|
||||
assertThat(greenMail.getReceivedMessages()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DkimServiceTest {
|
||||
|
||||
private static final String MESSAGE = "From: Alice <alice@ours.test>\r\nTo: bob@theirs.test\r\nSubject: Hello\r\n"
|
||||
+ "Date: Mon, 2 Nov 2026 10:00:00 +0000\r\nMessage-ID: <1@ours.test>\r\nMIME-Version: 1.0\r\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\nHello Bob,\r\nsee you.\r\n";
|
||||
|
||||
@TempDir
|
||||
Path keys;
|
||||
|
||||
/** Signer and a verifier that finds the signer's DNS record — like the receiving side would. */
|
||||
private DkimService[] pair() {
|
||||
DkimService signer = new DkimService("ours.test", "mail", keys, true, (method, selector, token) -> List.of());
|
||||
signer.init();
|
||||
DkimService verifier = new DkimService("other.test", "mail", keys.resolve("verifier"), false,
|
||||
(method, selector, token) -> "mail".contentEquals(selector) && "ours.test".contentEquals(token)
|
||||
? List.of(signer.dnsRecord()) : List.of());
|
||||
return new DkimService[]{signer, verifier};
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureFromOurDomainVerifies() throws Exception {
|
||||
DkimService[] p = pair();
|
||||
byte[] signed = p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String text = new String(signed, StandardCharsets.UTF_8);
|
||||
assertThat(text).startsWith("DKIM-Signature:").contains("c=relaxed/relaxed").contains("a=rsa-sha256")
|
||||
.contains("d=ours.test").contains("s=mail");
|
||||
DkimService.Verification result = p[1].verify(signed);
|
||||
assertThat(result.signed()).isTrue();
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.domain()).isEqualTo("ours.test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedBodyFailsVerification() throws Exception {
|
||||
DkimService[] p = pair();
|
||||
String signed = new String(p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
|
||||
byte[] tampered = signed.replace("see you.", "send money.").getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
DkimService.Verification result = p[1].verify(tampered);
|
||||
assertThat(result.signed()).isTrue();
|
||||
assertThat(result.passed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsignedMessageIsReportedAsNone() {
|
||||
DkimService.Verification result = pair()[1].verify(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(result.signed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mailFromAnotherDomainIsNotSigned() throws Exception {
|
||||
byte[] foreign = MESSAGE.replace("alice@ours.test", "eve@elsewhere.test").getBytes(StandardCharsets.UTF_8);
|
||||
assertThat(pair()[0].sign(foreign)).isEqualTo(foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyIsKeptAcrossRestartsAndOldPkcs1FilesLoad() throws Exception {
|
||||
DkimService first = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||
first.init();
|
||||
DkimService second = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||
second.init();
|
||||
assertThat(second.dnsRecord()).isEqualTo(first.dnsRecord()).startsWith("v=DKIM1; k=rsa; p=");
|
||||
|
||||
// the previous version wrote "BEGIN RSA PRIVATE KEY" (PKCS#1) and could not read it back
|
||||
Path legacy = keys.resolve("legacy");
|
||||
Files.createDirectories(legacy);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
RSAPrivateKey key = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||
try (var writer = Files.newBufferedWriter(legacy.resolve("dkim-private.pem"));
|
||||
var pem = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(writer)) {
|
||||
pem.writeObject(key);
|
||||
}
|
||||
assertThat(Files.readString(legacy.resolve("dkim-private.pem"))).contains("BEGIN RSA PRIVATE KEY");
|
||||
DkimService loaded = new DkimService("ours.test", "mail", legacy, true, (m, s, t) -> List.of());
|
||||
loaded.init();
|
||||
assertThat(loaded.dnsRecord()).isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LocalDeliveryTest {
|
||||
|
||||
private static MimeMessage parse(String text) throws Exception {
|
||||
return new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersHtmlInNestedAlternativeAndSkipsAttachments() throws Exception {
|
||||
MimeMessage message = parse("""
|
||||
Content-Type: multipart/mixed; boundary="m"\r
|
||||
\r
|
||||
--m\r
|
||||
Content-Type: multipart/alternative; boundary="a"\r
|
||||
\r
|
||||
--a\r
|
||||
Content-Type: text/plain\r
|
||||
\r
|
||||
plain\r
|
||||
--a\r
|
||||
Content-Type: text/html\r
|
||||
\r
|
||||
<p>html</p>\r
|
||||
--a--\r
|
||||
--m\r
|
||||
Content-Type: text/html\r
|
||||
Content-Disposition: attachment; filename="x.html"\r
|
||||
\r
|
||||
<p>attached</p>\r
|
||||
--m--\r
|
||||
""");
|
||||
|
||||
LocalDelivery.Body body = LocalDelivery.body(message);
|
||||
assertThat(body.html()).isTrue();
|
||||
assertThat(body.text()).contains("<p>html</p>").doesNotContain("attached");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainTextMessage() throws Exception {
|
||||
LocalDelivery.Body body = LocalDelivery.body(parse("Subject: x\r\n\r\njust text\r\n"));
|
||||
assertThat(body.html()).isFalse();
|
||||
assertThat(body.text()).isEqualTo("just text\r\n");
|
||||
}
|
||||
}
|
||||
@@ -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<LocalDelivery.FilePart> 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.delivery.OutboundQueue;
|
||||
import com.mailserver.delivery.SmtpDeliveryClient;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import com.mailserver.service.DkimService;
|
||||
import com.mailserver.service.LocalDelivery;
|
||||
import com.mailserver.service.UserService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** Mail trap (75.4): existing mailboxes keep their mail, everything else goes into the catch-all mailbox. */
|
||||
class MailRouterTest {
|
||||
|
||||
private final UserRepository users = mock(UserRepository.class);
|
||||
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
|
||||
private final OutboundQueue queue = mock(OutboundQueue.class);
|
||||
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
|
||||
|
||||
private MailRouter router(String catchAll) {
|
||||
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
|
||||
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
|
||||
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF);
|
||||
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
|
||||
queue, mock(DkimService.class), client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutCatchAllUnknownAndOutsideAddressesAreRefused() {
|
||||
MailRouter router = router("");
|
||||
|
||||
assertThat(router.route("bob@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
|
||||
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.RELAY_DENIED);
|
||||
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND_DISABLED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void catchAllTakesEverythingWithoutItsOwnMailboxButExistingMailboxesKeepTheirMail() throws Exception {
|
||||
MailRouter router = router("trap@mail.test");
|
||||
|
||||
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.LOCAL);
|
||||
assertThat(router.mailbox("bob@mail.test")).isEqualTo("bob@mail.test");
|
||||
assertThat(router.mailbox("user@gmail.com")).isEqualTo("trap@mail.test");
|
||||
|
||||
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "no-reply@jambotron.com", null, List.of(
|
||||
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
|
||||
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
|
||||
|
||||
verify(localDelivery).deliver(any(), eq("trap@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
|
||||
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
|
||||
verify(queue, never()).enqueue(any(), any(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SmtpServerTest {
|
||||
|
||||
/** Mailboxes of example.test; alice can log in. */
|
||||
static class FakeBackend implements SmtpBackend {
|
||||
final Map<String, String> passwords = Map.of("alice@example.test", "secret");
|
||||
final List<String> mailboxes = List.of("alice@example.test", "bob@example.test");
|
||||
final List<Envelope> envelopes = new CopyOnWriteArrayList<>();
|
||||
final List<String> messages = new CopyOnWriteArrayList<>();
|
||||
volatile boolean failStore;
|
||||
|
||||
@Override
|
||||
public boolean authenticate(String username, String password) {
|
||||
return password.equals(passwords.get(username));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Route route(String recipient, String authenticatedUser) {
|
||||
if (recipient.endsWith("@example.test")) {
|
||||
return mailboxes.contains(recipient) ? Route.LOCAL : Route.UNKNOWN_USER;
|
||||
}
|
||||
return authenticatedUser == null ? Route.RELAY_DENIED : Route.OUTBOUND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void accept(Envelope envelope, byte[] message) {
|
||||
if (failStore) {
|
||||
throw new IllegalStateException("database down");
|
||||
}
|
||||
envelopes.add(envelope);
|
||||
messages.add(new String(message, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private FakeBackend backend;
|
||||
private SmtpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void start() {
|
||||
backend = new FakeBackend();
|
||||
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true,
|
||||
Duration.ofSeconds(30)), backend);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesMailFromARealClient() throws Exception {
|
||||
Properties props = new Properties();
|
||||
props.put("mail.smtp.host", "127.0.0.1");
|
||||
props.put("mail.smtp.port", String.valueOf(server.port()));
|
||||
Session session = Session.getInstance(props);
|
||||
MimeMessage message = new MimeMessage(session);
|
||||
message.setFrom(new InternetAddress("someone@elsewhere.test"));
|
||||
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
|
||||
message.setSubject("Hello");
|
||||
message.setText("first line\n.starts with a dot\nlast line");
|
||||
|
||||
Transport.send(message);
|
||||
|
||||
assertThat(backend.envelopes).hasSize(1);
|
||||
SmtpBackend.Envelope envelope = backend.envelopes.get(0);
|
||||
assertThat(envelope.mailFrom()).isEqualTo("someone@elsewhere.test");
|
||||
assertThat(envelope.authenticatedUser()).isNull();
|
||||
assertThat(envelope.recipients()).containsExactly(new SmtpBackend.Recipient("bob@example.test", SmtpBackend.Route.LOCAL));
|
||||
assertThat(backend.messages.get(0))
|
||||
.startsWith("Received: from ")
|
||||
.contains("by mx.example.test with ESMTP;")
|
||||
.contains("Subject: Hello")
|
||||
.contains("\r\n.starts with a dot\r\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesPipelinedCommandsInOnePacketAndLinesSplitAcrossPackets() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.write("EHLO c.test\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\nDATA\r\n");
|
||||
assertThat(client.readReply()).startsWith("250-mx.example.test");
|
||||
assertThat(client.read()).startsWith("250 2.1.0");
|
||||
assertThat(client.read()).startsWith("250 2.1.5");
|
||||
assertThat(client.read()).startsWith("354");
|
||||
client.write("Subject: split\r\n\r\nhal");
|
||||
client.write("f a line\r\n..dot\r\n.");
|
||||
client.write("\r\nQUIT\r\n");
|
||||
assertThat(client.read()).startsWith("250 2.0.0");
|
||||
assertThat(client.read()).startsWith("221");
|
||||
}
|
||||
assertThat(backend.messages.get(0)).endsWith("Subject: split\r\n\r\nhalf a line\r\n.dot\r\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesToRelayForStrangers() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO spammer.test");
|
||||
client.readReply();
|
||||
client.send("MAIL FROM:<spam@spammer.test>");
|
||||
assertThat(client.send("RCPT TO:<victim@gmail.com>")).startsWith("550 5.7.1 Relaying denied");
|
||||
assertThat(client.send("RCPT TO:<nobody@example.test>")).startsWith("550 5.1.1");
|
||||
assertThat(client.send("DATA")).startsWith("554");
|
||||
}
|
||||
assertThat(backend.envelopes).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void relaysForALoggedInUserOnlyFromHisOwnAddress() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
assertThat(client.readReplyLines()).contains("250-AUTH PLAIN LOGIN");
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0wrong"))).startsWith("535");
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0Alice@Example.test\0secret"))).startsWith("235");
|
||||
assertThat(client.send("MAIL FROM:<bob@example.test>")).startsWith("553");
|
||||
assertThat(client.send("MAIL FROM:<alice@example.test>")).startsWith("250");
|
||||
assertThat(client.send("RCPT TO:<friend@gmail.com>")).startsWith("250");
|
||||
assertThat(client.send("DATA")).startsWith("354");
|
||||
assertThat(client.send("Subject: out\r\n\r\nhi\r\n.")).startsWith("250");
|
||||
}
|
||||
SmtpBackend.Envelope envelope = backend.envelopes.get(0);
|
||||
assertThat(envelope.authenticatedUser()).isEqualTo("alice@example.test");
|
||||
assertThat(envelope.recipients()).containsExactly(new SmtpBackend.Recipient("friend@gmail.com", SmtpBackend.Route.OUTBOUND));
|
||||
assertThat(backend.messages.get(0)).contains("with ESMTPA;");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authLoginWorksToo() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
client.readReply();
|
||||
assertThat(client.send("AUTH LOGIN")).isEqualTo("334 " + b64("Username:"));
|
||||
assertThat(client.send(b64("alice@example.test"))).isEqualTo("334 " + b64("Password:"));
|
||||
assertThat(client.send(b64("secret"))).startsWith("235");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTooBigMessagesAndKeepsTheSession() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("HELO c");
|
||||
client.send("MAIL FROM:<x@elsewhere.test>");
|
||||
client.send("RCPT TO:<bob@example.test>");
|
||||
client.send("DATA");
|
||||
assertThat(client.send("x".repeat(3000) + "\r\n.")).startsWith("552");
|
||||
assertThat(client.send("MAIL FROM:<x@elsewhere.test> SIZE=5000")).startsWith("552");
|
||||
assertThat(client.send("NOOP")).startsWith("250");
|
||||
}
|
||||
assertThat(backend.envelopes).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void answers451WhenTheMessageCannotBeStored() throws Exception {
|
||||
backend.failStore = true;
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("HELO c");
|
||||
client.send("MAIL FROM:<x@elsewhere.test>");
|
||||
client.send("RCPT TO:<bob@example.test>");
|
||||
client.send("DATA");
|
||||
assertThat(client.send("Subject: x\r\n\r\nbody\r\n.")).startsWith("451");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTlsAuthIsOffWhenNotAllowed() throws Exception {
|
||||
server.stop();
|
||||
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
|
||||
Duration.ofSeconds(30)), backend);
|
||||
server.start();
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
assertThat(client.readReplyLines()).noneMatch(line -> line.contains("AUTH"));
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesPaths() {
|
||||
assertThat(SmtpServer.path("<A@B.test> SIZE=10")).isEqualTo("a@b.test");
|
||||
assertThat(SmtpServer.path("<>")).isEmpty();
|
||||
assertThat(SmtpServer.path("<@relay.test:u@b.test>")).isEqualTo("u@b.test");
|
||||
assertThat(SmtpServer.path("u@b.test")).isNull();
|
||||
assertThat(SmtpServer.path("<a b@c>")).isNull();
|
||||
}
|
||||
|
||||
private static String b64(String text) {
|
||||
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Line-oriented test client. */
|
||||
static final class Client implements AutoCloseable {
|
||||
private final Socket socket;
|
||||
private final BufferedReader in;
|
||||
private final OutputStream out;
|
||||
|
||||
Client(int port) throws Exception {
|
||||
socket = new Socket("127.0.0.1", port);
|
||||
socket.setSoTimeout(5000);
|
||||
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
out = socket.getOutputStream();
|
||||
}
|
||||
|
||||
void write(String text) throws Exception {
|
||||
out.write(text.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
Thread.sleep(50); // separate TCP packets
|
||||
}
|
||||
|
||||
String send(String line) throws Exception {
|
||||
out.write((line + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
return read();
|
||||
}
|
||||
|
||||
String read() throws Exception {
|
||||
return in.readLine();
|
||||
}
|
||||
|
||||
/** First line of a multi-line reply; the rest is consumed. */
|
||||
String readReply() throws Exception {
|
||||
return readReplyLines().get(0);
|
||||
}
|
||||
|
||||
List<String> readReplyLines() throws Exception {
|
||||
List<String> lines = new ArrayList<>();
|
||||
String line;
|
||||
do {
|
||||
line = in.readLine();
|
||||
lines.add(line);
|
||||
} while (line != null && line.length() > 3 && line.charAt(3) == '-');
|
||||
return lines;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# Test deploy on Jambotron (v0.2.3 75.3): pick this file in the deploy wizard.
|
||||
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
|
||||
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
|
||||
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
|
||||
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in /app/data/initial-passwords.txt
|
||||
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
|
||||
name: reactive-mail
|
||||
|
||||
services:
|
||||
secrets-init:
|
||||
image: alpine:3.20
|
||||
restart: "no"
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
umask 077
|
||||
for name in DB_PASSWORD JWT_SECRET; do
|
||||
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
|
||||
done
|
||||
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
|
||||
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
|
||||
volumes:
|
||||
- secrets:/secrets
|
||||
|
||||
db:
|
||||
# Debian, not -alpine: on Jambotron staging VMs (Alpine 3.24, kernel 6.18) the Alpine build of postgres
|
||||
# dies with "Bus error" even on --version; the Debian build works (2026-11-05)
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
secrets-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
POSTGRES_DB: mailserver
|
||||
POSTGRES_USER: mail
|
||||
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- secrets:/run/mail-secrets:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
mem_limit: 256m
|
||||
|
||||
mail:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "8080" # web client + API → domain in Jambotron
|
||||
- "3025" # SMTP, inside the network only
|
||||
environment:
|
||||
DB_HOST: db
|
||||
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
|
||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
|
||||
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
|
||||
# mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox
|
||||
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test}
|
||||
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"
|
||||
DMARC_ENABLED: "false"
|
||||
volumes:
|
||||
- data:/app/data
|
||||
- secrets:/run/mail-secrets:ro
|
||||
mem_limit: 512m
|
||||
|
||||
volumes:
|
||||
secrets:
|
||||
pgdata:
|
||||
data:
|
||||
@@ -1,91 +0,0 @@
|
||||
services:
|
||||
# PostgreSQL — база данных
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mail-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: mailserver
|
||||
POSTGRES_USER: mail
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mail"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Почтовый сервер (Spring Boot WebFlux)
|
||||
mail-server:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mail-server
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
SPRING_R2DBC_URL: r2dbc:postgresql://postgres:5432/mailserver
|
||||
SPRING_R2DBC_USERNAME: mail
|
||||
SPRING_R2DBC_PASSWORD: secret
|
||||
SPRING_FLYWAY_URL: jdbc:postgresql://postgres:5432/mailserver
|
||||
SPRING_FLYWAY_USER: mail
|
||||
SPRING_FLYWAY_PASSWORD: secret
|
||||
MAILSERVER_SMTP_HOSTNAME: mail.jambotron.com
|
||||
MAILSERVER_SMTP_PORT: 3025
|
||||
MAILSERVER_RELAY_ENABLED: "true"
|
||||
MAILSERVER_RELAY_PORT: 25
|
||||
MAILSERVER_DKIM_DOMAIN: jambotron.com
|
||||
ports:
|
||||
- "3025:3025" # SMTP входящие (не через Caddy)
|
||||
volumes:
|
||||
- attachments:/app/attachments
|
||||
- dkim-keys:/app/dkim-keys
|
||||
- ./frontend/dist/frontend/browser:/usr/share/caddy # Статический фронтенд
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
labels:
|
||||
# Caddy Docker Proxy — автоматический HTTPS + reverse proxy
|
||||
caddy: mail.jambotron.com
|
||||
caddy.route.api.reverse_proxy: "*{{upstreams 8080}}"
|
||||
caddy.route.api.header_up.Host: "{host}"
|
||||
caddy.route.api.header_up.X-Real-IP: "{remote_host}"
|
||||
caddy.route.api.header_up.X-Forwarded-For: "{remote_host}"
|
||||
caddy.route.api.header_up.X-Forwarded-Proto: "{scheme}"
|
||||
# Статический фронтенд (Angular) — file_server для всех остальных путей
|
||||
caddy.route.static.root: "* /usr/share/caddy"
|
||||
caddy.route.static.file_server: "*"
|
||||
caddy.route.static.encode: "gzip"
|
||||
|
||||
# Caddy Docker Proxy — автоматически генерирует Caddyfile из labels
|
||||
caddy:
|
||||
image: lucaslorentz/caddy-docker-proxy:2.8-alpine
|
||||
container_name: mail-caddy
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp" # ACME DNS-01 challenge
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
environment:
|
||||
CADDY_DOCKER_CADDYFILE: ""
|
||||
CADDY_DOCKER_AGREE: "true"
|
||||
CADDY_DOCKER_TLS_DEFAULT_ROUTE: "true"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
attachments:
|
||||
dkim-keys:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
+4
-39
@@ -1,32 +1,8 @@
|
||||
version: '3.8'
|
||||
|
||||
# Local development: Postgres on localhost:5433 for `./gradlew :backend:bootRun --args='--spring.profiles.active=dev'`
|
||||
# and `cd frontend && npx ng serve` (proxy /api → :8080). Passwords here are for this laptop only.
|
||||
services:
|
||||
mail-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./backend/Dockerfile
|
||||
ports:
|
||||
- "8080:8080" # REST API
|
||||
- "3026:3025" # SMTP (приём писем)
|
||||
- "1443:143" # IMAP (доступ к письмам)
|
||||
volumes:
|
||||
- attachments:/app/attachments
|
||||
- dkim-keys:/app/dkim-keys
|
||||
environment:
|
||||
- SPRING_R2DBC_URL=r2dbc:postgresql://db:5432/mailserver
|
||||
- SPRING_R2DBC_USERNAME=mail
|
||||
- SPRING_R2DBC_PASSWORD=secret
|
||||
- SPRING_FLYWAY_URL=jdbc:postgresql://db:5432/mailserver
|
||||
- SPRING_FLYWAY_USER=mail
|
||||
- SPRING_FLYWAY_PASSWORD=secret
|
||||
- SPRING_MAIL_HOST=mailpit
|
||||
- SPRING_MAIL_PORT=1025
|
||||
depends_on:
|
||||
- db
|
||||
- mailpit
|
||||
|
||||
db:
|
||||
image: postgres:17
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: mailserver
|
||||
POSTGRES_USER: mail
|
||||
@@ -34,18 +10,7 @@ services:
|
||||
volumes:
|
||||
- pg-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit
|
||||
ports:
|
||||
- "1025:1025" # SMTP
|
||||
- "8025:8025" # Web UI
|
||||
volumes:
|
||||
- mailpit-data:/data
|
||||
- "127.0.0.1:5433:5432"
|
||||
|
||||
volumes:
|
||||
attachments:
|
||||
pg-data:
|
||||
mailpit-data:
|
||||
dkim-keys:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@jambotron:registry=https://gitea.jambotron.com/api/packages/jambotron_gang/npm/
|
||||
+10
-6
@@ -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"
|
||||
@@ -87,9 +88,12 @@
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:8080",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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: '' }
|
||||
];
|
||||
|
||||
|
||||
@@ -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: `<router-outlet />`,
|
||||
styles: ``
|
||||
template: `<router-outlet /><app-overlays />`
|
||||
})
|
||||
export class App {}
|
||||
export class App {
|
||||
// applies the remembered palette before the first page renders
|
||||
private theme = inject(ThemeService);
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div class="glass-panel p-6">
|
||||
<h2 class="text-lg font-orbitron font-bold text-cyan-500 mb-6 flex items-center gap-2">
|
||||
<i class="fa-solid fa-pen-to-square text-pink-500"></i> COMPOSE MESSAGE
|
||||
</h2>
|
||||
|
||||
@if (success) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/40 text-cyan-500 text-sm font-mono">
|
||||
Message sent successfully!
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onSend()" class="space-y-4">
|
||||
<div>
|
||||
<label for="to" class="block text-xs font-mono text-cyan-500 mb-1">TO</label>
|
||||
<input id="to" type="email" [(ngModel)]="to" name="to" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all"
|
||||
placeholder="recipient@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="subject" class="block text-xs font-mono text-cyan-500 mb-1">SUBJECT</label>
|
||||
<input id="subject" type="text" [(ngModel)]="subject" name="subject" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all"
|
||||
placeholder="Enter subject...">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label for="body" class="text-xs font-mono text-cyan-500">BODY</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<span class="text-xs font-mono text-slate-400">HTML</span>
|
||||
<input type="checkbox" [(ngModel)]="isHtml" name="isHtml" class="sr-only">
|
||||
<span class="w-10 h-5 rounded-full bg-slate-900/50 border border-cyan-500/30 relative transition-all"
|
||||
[class.bg-cyan-500/30]="isHtml" [class.border-cyan-500]="isHtml">
|
||||
<span class="absolute top-0.5 left-0.5 w-3.5 h-3.5 rounded-full bg-cyan-500 transition-all"
|
||||
[class.translate-x-5]="isHtml"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<textarea id="body" [(ngModel)]="body" name="body" rows="10" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-500 transition-all resize-none"
|
||||
[placeholder]="isHtml ? 'Write HTML or Markdown...' : 'Write your message...'"></textarea>
|
||||
@if (isHtml) {
|
||||
<p class="text-xs text-slate-500 font-mono mt-1"><i class="fa-solid fa-lightbulb mr-1"></i>Supports Markdown: **bold**, *italic*, - lists, code </p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" [disabled]="sending"
|
||||
class="cyber-btn px-8 py-3 rounded-xl text-sm font-bold">
|
||||
<i class="fa-solid fa-paper-plane mr-1"></i> {{ sending ? 'SENDING...' : 'SEND' }}
|
||||
</button>
|
||||
<a routerLink="/inbox" class="px-6 py-3 rounded-xl text-xs font-mono text-slate-400 border border-slate-600 hover:text-slate-200 hover:border-slate-400 transition-all text-center">
|
||||
CANCEL
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class ComposeComponent {
|
||||
private mailService = inject(MailService);
|
||||
private router = inject(Router);
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2">
|
||||
<i class="fa-solid fa-users text-pink-500"></i> CONTACTS
|
||||
<span class="text-sm font-mono text-slate-400 ml-auto">{{ contacts().length }} contacts</span>
|
||||
</h2>
|
||||
|
||||
<!-- Форма добавления -->
|
||||
<div class="glass-panel p-5">
|
||||
<h3 class="text-sm font-orbitron font-bold text-yellow-400 mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-pen-to-square text-pink-500"></i> {{ editingContact() ? 'EDIT CONTACT' : 'ADD NEW CONTACT' }}
|
||||
</h3>
|
||||
<form (ngSubmit)="saveContact()" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">NAME</label>
|
||||
<input type="text" [(ngModel)]="formName" name="name"
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="John Doe">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">EMAIL *</label>
|
||||
<input type="email" [(ngModel)]="formEmail" name="email" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="john@example.com">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">PHONE</label>
|
||||
<input type="text" [(ngModel)]="formPhone" name="phone"
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="+1 234 567 8900">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-mono text-pink-400 mb-1">COMPANY</label>
|
||||
<input type="text" [(ngModel)]="formCompany" name="company"
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2.5 text-sm font-mono text-slate-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="Acme Corp">
|
||||
</div>
|
||||
<div class="md:col-span-2 flex gap-2">
|
||||
<button type="submit"
|
||||
class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
@if (editingContact()) {
|
||||
<i class="fa-solid fa-save mr-1"></i> UPDATE
|
||||
} @else {
|
||||
<i class="fa-solid fa-plus mr-1"></i> ADD
|
||||
}
|
||||
</button>
|
||||
@if (editingContact()) {
|
||||
<button type="button" (click)="cancelEdit()"
|
||||
class="px-4 py-2 rounded-xl text-xs font-mono text-slate-400 border border-slate-600/40 hover:text-slate-200 hover:border-slate-400 transition-all">
|
||||
<i class="fa-solid fa-xmark mr-1"></i> CANCEL
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Список контактов -->
|
||||
<div class="glass-panel p-5">
|
||||
@if (contacts().length === 0) {
|
||||
<div class="text-center py-12 text-slate-400 font-mono">
|
||||
<i class="fa-solid fa-address-card text-4xl mb-4 text-slate-600"></i>
|
||||
<p>No contacts yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2">
|
||||
@for (contact of contacts(); track contact.id) {
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all">
|
||||
<div class="w-10 h-10 rounded-full bg-purple-500/30 flex items-center justify-center text-cyan-400 font-bold text-lg">
|
||||
{{ (contact.name || contact.email).charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-slate-200 truncate">{{ contact.name || '(No name)' }}</div>
|
||||
<div class="text-xs text-slate-400 font-mono truncate">{{ contact.email }}</div>
|
||||
@if (contact.phone || contact.company) {
|
||||
<div class="text-xs text-slate-500 font-mono">
|
||||
@if (contact.company) {
|
||||
{{ contact.company }}
|
||||
}
|
||||
@if (contact.company && contact.phone) {
|
||||
•
|
||||
}
|
||||
@if (contact.phone) {
|
||||
{{ contact.phone }}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button (click)="editContact(contact)"
|
||||
class="px-2 py-1 text-xs font-mono text-cyan-400 hover:bg-cyan-500/10 rounded-lg transition-all">
|
||||
<i class="fa-solid fa-pen"></i>
|
||||
</button>
|
||||
<button (click)="deleteContact(contact.id!)"
|
||||
class="px-2 py-1 text-xs font-mono text-pink-400 hover:bg-pink-500/10 rounded-lg transition-all">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<Contact[]>([]);
|
||||
editingContact = signal<Contact | null>(null);
|
||||
|
||||
formName = '';
|
||||
formEmail = '';
|
||||
formPhone = '';
|
||||
formCompany = '';
|
||||
|
||||
constructor() {
|
||||
this.loadContacts();
|
||||
}
|
||||
|
||||
loadContacts(): void {
|
||||
this.mailService.getContacts().subscribe({
|
||||
next: (contacts) => this.contacts.set(contacts),
|
||||
error: (e) => console.error('Failed to load contacts', e)
|
||||
});
|
||||
}
|
||||
|
||||
saveContact(): void {
|
||||
if (!this.formEmail.trim()) return;
|
||||
|
||||
const contact: Contact = {
|
||||
name: this.formName.trim(),
|
||||
email: this.formEmail.trim(),
|
||||
phone: this.formPhone.trim() || undefined,
|
||||
company: this.formCompany.trim() || undefined
|
||||
};
|
||||
|
||||
if (this.editingContact()?.id) {
|
||||
this.mailService.updateContact(this.editingContact()!.id!, contact).subscribe({
|
||||
next: () => {
|
||||
this.resetForm();
|
||||
this.loadContacts();
|
||||
},
|
||||
error: (e) => console.error('Failed to update contact', e)
|
||||
});
|
||||
} else {
|
||||
this.mailService.addContact(contact).subscribe({
|
||||
next: () => {
|
||||
this.resetForm();
|
||||
this.loadContacts();
|
||||
},
|
||||
error: (e) => console.error('Failed to add contact', e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
editContact(contact: Contact): void {
|
||||
this.editingContact.set(contact);
|
||||
this.formName = contact.name || '';
|
||||
this.formEmail = contact.email || '';
|
||||
this.formPhone = contact.phone || '';
|
||||
this.formCompany = contact.company || '';
|
||||
}
|
||||
|
||||
cancelEdit(): void {
|
||||
this.editingContact.set(null);
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
deleteContact(id: number): void {
|
||||
if (confirm('Delete this contact?')) {
|
||||
this.mailService.deleteContact(id).subscribe({
|
||||
next: () => this.loadContacts(),
|
||||
error: (e) => console.error('Failed to delete contact', e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
this.formName = '';
|
||||
this.formEmail = '';
|
||||
this.formPhone = '';
|
||||
this.formCompany = '';
|
||||
this.editingContact.set(null);
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="space-y-4">
|
||||
<!-- Поиск -->
|
||||
<div class="glass-panel p-4">
|
||||
<div class="flex gap-3">
|
||||
<input [(ngModel)]="searchQuery" (keyup.enter)="loadMessages()"
|
||||
class="flex-1 bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-2 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="Search messages...">
|
||||
<button (click)="loadMessages()" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
<i class="fa-solid fa-magnifying-glass mr-1"></i> SEARCH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Список писем -->
|
||||
<div class="glass-panel p-4">
|
||||
<h2 class="text-lg font-orbitron font-bold text-cyan-300 mb-4 flex items-center gap-2">
|
||||
<i class="fa-solid fa-inbox text-pink-500"></i> {{ folderName() }}
|
||||
<span class="text-sm font-mono text-slate-400 ml-auto">{{ messages()?.length || 0 }} messages</span>
|
||||
</h2>
|
||||
|
||||
@if (!messages() || messages().length === 0) {
|
||||
<div class="text-center py-12 text-slate-400 font-mono">
|
||||
<i class="fa-solid fa-envelope-open-text text-4xl mb-4 text-slate-600"></i>
|
||||
<p>No messages yet</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-2">
|
||||
@for (msg of messages() || []; track msg.id) {
|
||||
<a routerLink="/message/{{ msg.id }}"
|
||||
class="block p-4 rounded-xl border border-cyan-500/10 hover:border-cyan-500/40 hover:bg-cyan-500/5 transition-all cursor-pointer group">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Индикатор прочитанности -->
|
||||
<div class="mt-1">
|
||||
@if (!msg.read) {
|
||||
<span class="w-3 h-3 rounded-full bg-pink-500 inline-block shadow-[0_0_8px_rgba(255,0,127,0.8)]"></span>
|
||||
} @else {
|
||||
<span class="w-3 h-3 rounded-full border border-slate-500 inline-block"></span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-bold text-sm text-slate-200 font-rajdhani truncate">
|
||||
{{ msg.senderName || msg.sender }}
|
||||
</span>
|
||||
@if (msg.spam) {
|
||||
<span class="px-2 py-0.5 text-xs rounded-full bg-yellow-500/20 text-yellow-400 font-mono">
|
||||
SPAM
|
||||
</span>
|
||||
}
|
||||
@if (msg.flagged) {
|
||||
<i class="fa-solid fa-star text-yellow-400 text-xs"></i>
|
||||
}
|
||||
</div>
|
||||
<div class="text-sm text-cyan-400 font-rajdhani truncate group-hover:text-pink-400 transition-colors">
|
||||
{{ msg.subject || '(No subject)' }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 font-mono truncate">
|
||||
{{ msg.body || '' | slice:0:100 }}{{ (msg.body || '').length > 100 ? '...' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-xs text-slate-400 font-mono">
|
||||
{{ msg.sentAt | date:'MMM d, HH:mm' }}
|
||||
</div>
|
||||
@if (msg.hasAttachments) {
|
||||
<span class="text-xs text-purple-400 font-mono"><i class="fa-solid fa-paperclip mr-1"></i>{{ msg.attachmentCount }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Пагинация -->
|
||||
@if (totalPages() > 1) {
|
||||
<div class="glass-panel p-4 flex items-center justify-center gap-2">
|
||||
<button (click)="goToPage(page() - 1)" [disabled]="page() === 0"
|
||||
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
||||
<i class="fa-solid fa-chevron-left mr-1"></i> Prev
|
||||
</button>
|
||||
@for (p of pageNumbers; track p) {
|
||||
<button (click)="goToPage(p)"
|
||||
[class.bg-pink-500/20]="p === page()" [class.text-pink-400]="p === page()"
|
||||
[class.border-pink-500]="p === page()"
|
||||
class="px-3 py-1 rounded-lg text-xs font-mono text-slate-400 border border-cyan-500/20 hover:bg-cyan-500/10 hover:text-cyan-300 transition-all">
|
||||
{{ p + 1 }}
|
||||
</button>
|
||||
}
|
||||
<button (click)="goToPage(page() + 1)" [disabled]="page() >= totalPages() - 1"
|
||||
class="px-3 py-1 rounded-lg text-xs font-mono text-cyan-400 border border-cyan-500/30 hover:bg-cyan-500/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
|
||||
Next <i class="fa-solid fa-chevron-right ml-1"></i>
|
||||
</button>
|
||||
<span class="text-xs font-mono text-slate-500 ml-2">
|
||||
Page {{ page() + 1 }} of {{ totalPages() }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class InboxComponent {
|
||||
private route = inject(ActivatedRoute);
|
||||
private mailService = inject(MailService);
|
||||
messages = signal<Message[]>([]);
|
||||
folderName = signal<string>('INBOX');
|
||||
searchQuery = '';
|
||||
folderId = signal<number>(1);
|
||||
page = signal<number>(0);
|
||||
totalPages = signal<number>(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;
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div #root class="min-h-screen relative theme-cyber-magenta" [class.light-theme]="isLightTheme()" [class.high-contrast]="isHighContrast()">
|
||||
<!-- Synthwave анимированный фон -->
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container">
|
||||
<div class="grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Контент -->
|
||||
<div class="relative z-10 flex flex-col min-h-screen">
|
||||
<!-- Шапка -->
|
||||
<header class="p-4 lg:p-6 border-b border-cyan-500/20 glass-panel rounded-none border-t-0 border-x-0 sticky top-0 z-40 backdrop-blur-xl">
|
||||
<div class="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
|
||||
<!-- Логотип -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center text-slate-950 font-black text-xl shadow-[0_0_15px_rgba(255,0,127,0.8)] font-orbitron">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-extrabold font-orbitron tracking-wider neon-text-primary">
|
||||
MAIL<span class="text-cyan-400 font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-xs text-cyan-300/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="flex items-center space-x-4 bg-slate-950/60 px-4 py-2 rounded-full border border-pink-500/30 text-xs font-mono">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-ping"></span>
|
||||
<span class="text-slate-300">SMTP: <span class="text-emerald-400 font-bold">ONLINE</span></span>
|
||||
</div>
|
||||
<span class="text-slate-600">|</span>
|
||||
<div class="text-slate-300">User: <span class="text-cyan-400 font-bold">{{ email }}</span></div>
|
||||
<span class="text-slate-600">|</span>
|
||||
<div class="text-slate-300">Folders: <span class="text-pink-400 font-bold">{{ folders().length }}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Контроли -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Выбор цветовой темы -->
|
||||
<div class="relative">
|
||||
<select #themeSelect (change)="onThemeChange(themeSelect.value)"
|
||||
class="bg-slate-900/90 text-cyan-300 border border-cyan-500/40 rounded-lg px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 cursor-pointer">
|
||||
@for (theme of themes; track theme.value) {
|
||||
<option [value]="theme.value" [selected]="currentTheme() === theme.value">
|
||||
{{ theme.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- High Contrast -->
|
||||
<button (click)="toggleHighContrast()"
|
||||
[attr.aria-pressed]="isHighContrast()"
|
||||
title="Toggle High Contrast Mode"
|
||||
class="px-3 py-1.5 rounded-lg border border-cyan-500/40 bg-slate-900/80 text-xs font-mono hover:bg-cyan-500/20 focus:outline-none focus:ring-2 focus:ring-pink-500 transition-all flex items-center gap-1.5">
|
||||
<i class="fa-solid fa-circle-half-stroke text-yellow-400"></i>
|
||||
<span>Contrast</span>
|
||||
</button>
|
||||
|
||||
<!-- Light/Dark -->
|
||||
<button (click)="toggleTheme()" class="cyber-btn px-3 py-1.5 rounded-lg text-xs font-bold">
|
||||
{{ isLightTheme() ? '🌙 Dark' : '☀️ Light' }}
|
||||
</button>
|
||||
|
||||
<!-- Logout -->
|
||||
<button (click)="onLogout()" class="cyber-btn px-4 py-1.5 rounded-lg text-xs font-bold">
|
||||
<i class="fa-solid fa-power-off mr-1"></i> Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Основная область -->
|
||||
<div class="flex flex-1 max-w-7xl mx-auto w-full p-4 gap-4">
|
||||
<!-- Боковая панель -->
|
||||
<aside class="w-64 flex-shrink-0">
|
||||
<div class="glass-panel p-4">
|
||||
<h2 class="text-sm font-orbitron font-bold text-cyan-300 mb-3 flex items-center gap-2">
|
||||
<i class="fa-solid fa-folder-tree text-pink-500"></i> FOLDERS
|
||||
</h2>
|
||||
<nav class="space-y-1">
|
||||
@for (folder of folders(); track folder.id) {
|
||||
<a routerLink="/inbox" [queryParams]="{folderId: folder.id}"
|
||||
routerLinkActive="bg-pink-500/20 text-pink-400 border-pink-500"
|
||||
[queryParamsHandling]="'merge'"
|
||||
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 border border-transparent transition-all">
|
||||
{{ folder.name }}
|
||||
@if (folder.unreadCount > 0) {
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-pink-500/30 text-pink-400 font-mono">
|
||||
{{ folder.unreadCount }}
|
||||
</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Контакты -->
|
||||
<div class="glass-panel p-4 mt-4">
|
||||
<a routerLink="/contacts"
|
||||
routerLinkActive="bg-cyan-500/20 text-cyan-300 border-cyan-500"
|
||||
class="block px-3 py-2 rounded-lg text-sm font-rajdhani text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 border border-transparent transition-all">
|
||||
<i class="fa-solid fa-address-book mr-2"></i> Contacts
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка написать -->
|
||||
<div class="glass-panel p-4 mt-4">
|
||||
<a routerLink="/compose" class="cyber-btn w-full py-3 rounded-xl text-xs font-bold block text-center">
|
||||
<i class="fa-solid fa-pen-to-square mr-2"></i> NEW MESSAGE
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Контент маршрутов -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Футер -->
|
||||
<footer class="p-4 border-t border-cyan-500/20 glass-panel rounded-none border-b-0 border-x-0 mt-6 text-center text-xs font-mono text-slate-400">
|
||||
<div class="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<div>MAILSERVER SYNTHWAVE © 2026</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-cyan-400"><i class="fa-solid fa-universal-access mr-1"></i> A11Y READY</span>
|
||||
<span class="text-pink-400"><i class="fa-solid fa-bolt mr-1"></i> HIGH PERF CSS3D</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class LayoutComponent {
|
||||
authService = inject(AuthService);
|
||||
mailService = inject(MailService);
|
||||
|
||||
email = this.authService.getEmail() || '';
|
||||
folders = signal<Folder[]>([]);
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="min-h-screen relative flex items-center justify-center theme-cyber-magenta">
|
||||
<!-- Synthwave анимированный фон -->
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container">
|
||||
<div class="grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма логина -->
|
||||
<div class="relative z-10 w-full max-w-md px-4">
|
||||
<div class="glass-panel p-8 border-2 border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)]">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center text-slate-950 font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold font-orbitron tracking-wider neon-text-primary mb-2">
|
||||
MAIL<span class="text-cyan-400 font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-sm text-cyan-300/70 font-mono tracking-widest uppercase">Synthwave Edition</p>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-pink-500/20 border border-pink-500/40 text-pink-400 text-sm font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onLogin()" class="space-y-4">
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-mono text-pink-400 mb-1">EMAIL</label>
|
||||
<input id="email" type="email" [(ngModel)]="email" name="email" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-mono text-pink-400 mb-1">PASSWORD</label>
|
||||
<input id="password" type="password" [(ngModel)]="password" name="password" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading"
|
||||
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
|
||||
@if (loading) {
|
||||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> CONNECTING...
|
||||
} @else {
|
||||
<i class="fa-solid fa-bolt mr-1"></i> LOGIN
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400 mt-6 font-rajdhani">
|
||||
No account?
|
||||
<a routerLink="/register" class="text-cyan-400 hover:text-pink-400 font-mono transition-colors">
|
||||
Register here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class LoginComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
error = '' as string | null;
|
||||
loading = false as boolean;
|
||||
|
||||
onLogin(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.authService.login({ email: this.email, password: this.password }).subscribe({
|
||||
next: () => this.router.navigate(['/inbox']),
|
||||
error: (err) => {
|
||||
this.error = 'Invalid email or password';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
<div class="glass-panel p-6">
|
||||
<!-- Заголовок письма -->
|
||||
<div class="mb-6 pb-4 border-b border-cyan-500/20">
|
||||
<h1 class="text-xl font-orbitron font-bold neon-text-secondary mb-3">
|
||||
{{ message.subject || '(No subject)' }}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm font-mono">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-cyan-500">FROM:</span>
|
||||
<span class="text-slate-200">{{ message.senderName || message.sender }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-cyan-500">DATE:</span>
|
||||
<span class="text-slate-200">{{ message.sentAt | date:'MMM d, y, HH:mm' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (message.spam) {
|
||||
<div class="mt-3 px-3 py-1.5 inline-block rounded-full bg-yellow-500/20 border border-yellow-500/40 text-yellow-500 text-xs font-mono">
|
||||
<i class="fa-solid fa-triangle-exclamation mr-1"></i> MARKED AS SPAM
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Тело письма -->
|
||||
<div class="prose prose-invert max-w-none mb-6">
|
||||
<p class="text-slate-300 font-rajdhani text-lg whitespace-pre-wrap leading-relaxed">
|
||||
{{ message.body }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Вложения -->
|
||||
@if (message.hasAttachments) {
|
||||
<div class="mb-6 p-4 rounded-xl bg-slate-950/60 border border-purple-500/30">
|
||||
<h3 class="text-sm font-orbitron font-bold text-purple-500 mb-3">
|
||||
<i class="fa-solid fa-paperclip mr-1"></i> ATTACHMENTS ({{ message.attachmentCount }})
|
||||
</h3>
|
||||
<p class="text-xs text-slate-400 font-mono">
|
||||
Attachments are available via the API
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="flex gap-3 pt-4 border-t border-cyan-500/20">
|
||||
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold">
|
||||
<i class="fa-solid fa-chevron-left mr-1"></i> BACK TO INBOX
|
||||
</a>
|
||||
<a [routerLink]="['/compose']" [queryParams]="{replyTo: message.sender, subject: 'Re: ' + (message.subject || '')}"
|
||||
class="px-6 py-2 rounded-xl text-xs font-mono text-cyan-500 border border-cyan-500/40 hover:bg-cyan-500/20 transition-all">
|
||||
<i class="fa-solid fa-reply mr-1"></i> REPLY
|
||||
</a>
|
||||
<a [routerLink]="['/compose']" [queryParams]="{forwardTo: '', forwardSubject: 'Fwd: ' + (message.subject || ''), forwardBody: message.body}"
|
||||
class="px-6 py-2 rounded-xl text-xs font-mono text-purple-500 border border-purple-500/40 hover:bg-purple-500/20 transition-all">
|
||||
<i class="fa-solid fa-share mr-1"></i> FORWARD
|
||||
</a>
|
||||
<button (click)="onDelete()" class="px-6 py-2 rounded-xl text-xs font-mono text-pink-500 border border-pink-500/40 hover:bg-pink-500/20 transition-all">
|
||||
<i class="fa-solid fa-trash mr-1"></i> DELETE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="glass-panel p-6 text-center py-12">
|
||||
<i class="fa-solid fa-envelope-open-text text-4xl mb-4 text-slate-600"></i>
|
||||
<p class="text-slate-400 font-mono">Message not found</p>
|
||||
<a routerLink="/inbox" class="cyber-btn px-6 py-2 rounded-xl text-xs font-bold mt-4 inline-block">
|
||||
← BACK TO INBOX
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class MessageDetailComponent {
|
||||
private mailService = inject(MailService);
|
||||
private route = inject(ActivatedRoute);
|
||||
|
||||
message: Message | null = null;
|
||||
messageId = 0;
|
||||
|
||||
constructor() {
|
||||
this.messageId = Number(this.route.snapshot.paramMap.get('id'));
|
||||
this.loadMessage();
|
||||
}
|
||||
|
||||
loadMessage(): void {
|
||||
this.mailService.getMessage(this.messageId).subscribe({
|
||||
next: (msg) => {
|
||||
this.message = msg;
|
||||
if (!msg.read) {
|
||||
this.mailService.markAsRead(this.messageId).subscribe();
|
||||
}
|
||||
},
|
||||
error: () => console.error('Failed to load message')
|
||||
});
|
||||
}
|
||||
|
||||
onDelete(): void {
|
||||
if (confirm('Delete this message?')) {
|
||||
this.mailService.deleteMessage(this.messageId).subscribe({
|
||||
next: () => (window.location.href = '/inbox'),
|
||||
error: () => console.error('Failed to delete message')
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="min-h-screen relative flex items-center justify-center theme-cyber-magenta">
|
||||
<!-- Synthwave анимированный фон -->
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container">
|
||||
<div class="grid-plane"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма регистрации -->
|
||||
<div class="relative z-10 w-full max-w-md px-4">
|
||||
<div class="glass-panel p-8 border-2 border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)]">
|
||||
<div class="text-center mb-8">
|
||||
<div class="w-16 h-16 mx-auto rounded-xl bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center text-slate-950 font-black text-2xl font-orbitron shadow-[0_0_20px_rgba(255,0,127,0.8)] mb-4">
|
||||
M
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold font-orbitron tracking-wider neon-text-primary mb-2">
|
||||
MAIL<span class="text-cyan-400 font-normal">SERVER</span>
|
||||
</h1>
|
||||
<p class="text-sm text-cyan-300/70 font-mono tracking-widest uppercase">Create your mailbox</p>
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-pink-500/20 border border-pink-500/40 text-pink-400 text-sm font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
}
|
||||
@if (success) {
|
||||
<div class="mb-4 p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/40 text-cyan-400 text-sm font-mono">
|
||||
Account created! Redirecting to login...
|
||||
</div>
|
||||
}
|
||||
|
||||
<form (ngSubmit)="onRegister()" class="space-y-4">
|
||||
<div>
|
||||
<label for="displayName" class="block text-xs font-mono text-pink-400 mb-1">DISPLAY NAME</label>
|
||||
<input id="displayName" type="text" [(ngModel)]="displayName" name="displayName" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="John Doe">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-mono text-pink-400 mb-1">EMAIL</label>
|
||||
<input id="email" type="email" [(ngModel)]="email" name="email" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-xs font-mono text-pink-400 mb-1">PASSWORD</label>
|
||||
<input id="password" type="password" [(ngModel)]="password" name="password" required
|
||||
class="w-full bg-slate-950/80 border border-cyan-500/40 rounded-xl px-4 py-3 text-sm text-slate-200 font-mono focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-cyan-400 transition-all"
|
||||
placeholder="••••••••">
|
||||
</div>
|
||||
|
||||
<button type="submit" [disabled]="loading"
|
||||
class="cyber-btn w-full py-3 rounded-xl text-sm font-bold mt-6">
|
||||
@if (loading) {
|
||||
<i class="fa-solid fa-spinner fa-spin mr-1"></i> CREATING...
|
||||
} @else {
|
||||
<i class="fa-solid fa-bolt mr-1"></i> REGISTER
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate-400 mt-6 font-rajdhani">
|
||||
Already have an account?
|
||||
<a routerLink="/login" class="text-cyan-400 hover:text-pink-400 font-mono transition-colors">
|
||||
Login here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: ``
|
||||
})
|
||||
export default class RegisterComponent {
|
||||
private authService = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
displayName = '';
|
||||
email = '';
|
||||
password = '';
|
||||
error = '' as string | null;
|
||||
success = false as boolean;
|
||||
loading = false as boolean;
|
||||
|
||||
onRegister(): void {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
this.authService.register({
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
displayName: this.displayName
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
this.success = true;
|
||||
this.loading = false;
|
||||
setTimeout(() => this.router.navigate(['/login']), 1500);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error = 'Registration failed. Email may already be taken.';
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
|
||||
|
||||
/** The mail server REST API (same origin, /api). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
config(): Observable<ServerConfig> { return this.http.get<ServerConfig>('/api/config'); }
|
||||
|
||||
// profile
|
||||
me(): Observable<Profile> { return this.http.get<Profile>('/api/me'); }
|
||||
updateMe(displayName: string): Observable<Profile> { return this.http.put<Profile>('/api/me', { displayName }); }
|
||||
changePassword(currentPassword: string, newPassword: string): Observable<unknown> {
|
||||
return this.http.put('/api/me/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
// folders
|
||||
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
|
||||
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
|
||||
renameFolder(id: number, name: string): Observable<unknown> { return this.http.put(`/api/folders/${id}`, { name }); }
|
||||
deleteFolder(id: number): Observable<unknown> { return this.http.delete(`/api/folders/${id}`); }
|
||||
emptyFolder(id: number): Observable<{ deleted: number }> {
|
||||
return this.http.delete<{ deleted: number }>(`/api/folders/${id}/messages`);
|
||||
}
|
||||
|
||||
// messages
|
||||
messages(folderId: number): Observable<Message[]> {
|
||||
return this.http.get<Message[]>('/api/messages', { params: { folderId } });
|
||||
}
|
||||
search(folderId: number, keyword: string): Observable<Message[]> {
|
||||
return this.http.get<Message[]>('/api/messages/search', { params: { folderId, keyword } });
|
||||
}
|
||||
message(id: number): Observable<Message> { return this.http.get<Message>(`/api/messages/${id}`); }
|
||||
updateMessage(id: number, change: { read?: boolean; flagged?: boolean; folderId?: number }): Observable<Message> {
|
||||
return this.http.patch<Message>(`/api/messages/${id}`, change);
|
||||
}
|
||||
deleteMessage(id: number): Observable<unknown> { return this.http.delete(`/api/messages/${id}`); }
|
||||
|
||||
// compose
|
||||
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
|
||||
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
|
||||
send(draft: Draft, draftId: number | null): Observable<Message> {
|
||||
return this.http.post<Message>('/api/send', { ...draft, draftId });
|
||||
}
|
||||
|
||||
// attachments
|
||||
attachments(messageId: number): Observable<Attachment[]> {
|
||||
return this.http.get<Attachment[]>(`/api/messages/${messageId}/attachments`);
|
||||
}
|
||||
upload(messageId: number, file: File): Observable<Attachment> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
return this.http.post<Attachment>(`/api/messages/${messageId}/attachments`, form);
|
||||
}
|
||||
deleteAttachment(id: number): Observable<unknown> { return this.http.delete(`/api/attachments/${id}`); }
|
||||
download(id: number): Observable<Blob> {
|
||||
return this.http.get(`/api/attachments/${id}/download`, { responseType: 'blob' });
|
||||
}
|
||||
|
||||
// contacts
|
||||
contacts(): Observable<Contact[]> { return this.http.get<Contact[]>('/api/contacts'); }
|
||||
addContact(contact: Contact): Observable<unknown> { return this.http.post('/api/contacts', contact); }
|
||||
updateContact(id: number, contact: Contact): Observable<unknown> { return this.http.put(`/api/contacts/${id}`, contact); }
|
||||
deleteContact(id: number): Observable<unknown> { return this.http.delete(`/api/contacts/${id}`); }
|
||||
|
||||
// blacklist
|
||||
blacklist(): Observable<BlacklistEntry[]> { return this.http.get<BlacklistEntry[]>('/api/spam/blacklist'); }
|
||||
block(senderEmail: string, reason: string): Observable<unknown> {
|
||||
return this.http.post('/api/spam/blacklist', { senderEmail, reason });
|
||||
}
|
||||
unblock(senderEmail: string): Observable<unknown> {
|
||||
return this.http.delete('/api/spam/blacklist', { params: { senderEmail } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
interface TokenResponse { token: string; email: string; }
|
||||
|
||||
/** JWT in localStorage; who is logged in. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http = inject(HttpClient);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly email = signal<string | null>(read('email'));
|
||||
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
|
||||
|
||||
login(email: string, password: string): Observable<TokenResponse> {
|
||||
return this.http.post<TokenResponse>('/api/login', { email, password }).pipe(tap(r => this.store(r)));
|
||||
}
|
||||
|
||||
register(email: string, password: string, displayName: string): Observable<TokenResponse> {
|
||||
return this.http.post<TokenResponse>('/api/register', { email, password, displayName }).pipe(tap(r => this.store(r)));
|
||||
}
|
||||
|
||||
token(): string | null { return read('token'); }
|
||||
|
||||
logout(): void {
|
||||
this.clear();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
try {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('email');
|
||||
} catch { /* storage blocked */ }
|
||||
this.email.set(null);
|
||||
}
|
||||
|
||||
private store(response: TokenResponse): void {
|
||||
try {
|
||||
localStorage.setItem('token', response.token);
|
||||
localStorage.setItem('email', response.email);
|
||||
} catch { /* storage blocked: logged in for this tab only */ }
|
||||
this.email.set(response.email);
|
||||
}
|
||||
}
|
||||
|
||||
function read(key: string): string | null {
|
||||
try { return localStorage.getItem(key); } catch { return null; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
const token = auth.token();
|
||||
const request = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
// not logged in or the token expired → to the login form
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && !req.url.endsWith('/login')) {
|
||||
auth.clear();
|
||||
router.navigate(['/login']);
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const authGuard: CanActivateFn = () =>
|
||||
inject(AuthService).loggedIn() ? true : inject(Router).createUrlTree(['/login']);
|
||||
|
||||
export const guestGuard: CanActivateFn = () =>
|
||||
inject(AuthService).loggedIn() ? inject(Router).createUrlTree(['/']) : true;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Folder, Profile } from './models';
|
||||
|
||||
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MailStateService {
|
||||
private api = inject(ApiService);
|
||||
|
||||
readonly folders = signal<Folder[]>([]);
|
||||
readonly profile = signal<Profile | null>(null);
|
||||
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
|
||||
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
|
||||
|
||||
system(name: string): Folder | null {
|
||||
return this.folders().find(f => f.system && f.name === name) ?? null;
|
||||
}
|
||||
|
||||
folder(id: number): Folder | null {
|
||||
return this.folders().find(f => f.id === id) ?? null;
|
||||
}
|
||||
|
||||
refreshFolders(): void {
|
||||
this.api.folders().subscribe({ next: folders => this.folders.set(folders), error: () => {} });
|
||||
}
|
||||
|
||||
refreshProfile(): void {
|
||||
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.folders.set([]);
|
||||
this.profile.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface Folder {
|
||||
id: number;
|
||||
name: string;
|
||||
system: boolean;
|
||||
messageCount: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number;
|
||||
folderId: number;
|
||||
senderEmail: string;
|
||||
senderName?: string | null;
|
||||
recipientEmail: string;
|
||||
ccEmail?: string | null;
|
||||
subject: string;
|
||||
body: string;
|
||||
html: boolean;
|
||||
read: boolean;
|
||||
flagged: boolean;
|
||||
spam: boolean;
|
||||
messageSize?: number | null;
|
||||
receivedAt: string;
|
||||
hasAttachments: boolean;
|
||||
attachmentCount: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id?: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface BlacklistEntry {
|
||||
id: number;
|
||||
senderEmail: string;
|
||||
reason?: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
email: string;
|
||||
displayName: string;
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
registrationEnabled: boolean;
|
||||
domains: string[];
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
to: string;
|
||||
cc: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
html: boolean;
|
||||
}
|
||||
|
||||
/** System folders: label and icon in the web client. */
|
||||
export const FOLDER_VIEW: Record<string, { label: string; icon: string }> = {
|
||||
INBOX: { label: 'Inbox', icon: 'inbox' },
|
||||
Drafts: { label: 'Drafts', icon: 'edit_note' },
|
||||
Sent: { label: 'Sent', icon: 'send' },
|
||||
Spam: { label: 'Spam', icon: 'report' },
|
||||
Trash: { label: 'Trash', icon: 'delete' }
|
||||
};
|
||||
|
||||
export function folderLabel(folder: Folder): string {
|
||||
return folder.system ? (FOLDER_VIEW[folder.name]?.label ?? folder.name) : folder.name;
|
||||
}
|
||||
|
||||
export function folderIcon(folder: Folder): string {
|
||||
return folder.system ? (FOLDER_VIEW[folder.name]?.icon ?? 'folder') : 'folder';
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
const value = bytes ?? 0;
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Text of an API error: our {error}, Spring's {message}, or the status. */
|
||||
export function errorText(error: unknown): string {
|
||||
const e = error as { error?: { error?: string; message?: string; status?: number } | string; status?: number; message?: string };
|
||||
if (e?.error && typeof e.error === 'object') {
|
||||
// Spring's error body: {status, error: "Bad Request", message: "No such mailbox: …"} — the reason is in message
|
||||
const body = e.error;
|
||||
const springBody = typeof body.status === 'number';
|
||||
return (springBody ? body.message || body.error : body.error || body.message) || `Error ${e.status ?? ''}`.trim();
|
||||
}
|
||||
if (typeof e?.error === 'string' && e.error) return e.error;
|
||||
if (e?.status === 0) return 'Server is not reachable';
|
||||
return e?.message || 'Something went wrong';
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn } from '@angular/common/http';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req: HttpRequest<unknown>, next: HttpHandlerFn) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (token) {
|
||||
const cloned = req.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
return next(cloned);
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
@@ -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<T> {
|
||||
content: T[];
|
||||
number: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
}
|
||||
@@ -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: `
|
||||
<app-background />
|
||||
<main class="relative z-10 min-h-screen flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="flex items-center justify-center gap-3 mb-6">
|
||||
<div class="w-12 h-12 rounded-lg bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center
|
||||
text-slate-950 font-black text-2xl shadow-[0_0_15px_rgba(255,0,127,0.8)] font-orbitron">M</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold font-orbitron tracking-wider neon-text-primary">
|
||||
SYNTH <span class="text-cyan-400 font-normal">MAIL</span>
|
||||
</h1>
|
||||
<p class="text-xs text-cyan-300/70 font-mono tracking-widest uppercase">Reactive mail server</p>
|
||||
</div>
|
||||
</div>
|
||||
<section class="glass-panel static p-6 sm:p-8" [attr.aria-labelledby]="'auth-title'">
|
||||
<h2 id="auth-title" class="text-xl font-orbitron font-bold neon-text-secondary mb-1">{{ title() }}</h2>
|
||||
<p class="text-xs text-slate-400 font-mono mb-6">{{ subtitle() }}</p>
|
||||
<ng-content />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
`
|
||||
})
|
||||
export class AuthFrameComponent {
|
||||
readonly title = input.required<string>();
|
||||
readonly subtitle = input('');
|
||||
}
|
||||
@@ -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: `
|
||||
<app-auth-frame title="LOG IN" subtitle="Your mailbox address and password">
|
||||
<form (ngSubmit)="submit()" class="space-y-4">
|
||||
<div>
|
||||
<label class="synth-label" for="email">Email</label>
|
||||
<input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required
|
||||
autocomplete="username" placeholder="you@domain">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password">Password</label>
|
||||
<div class="relative">
|
||||
<input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10"
|
||||
[(ngModel)]="password" required autocomplete="current-password">
|
||||
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
|
||||
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
||||
<mat-icon class="!text-lg">error</mat-icon>{{ error() }}
|
||||
</p>
|
||||
}
|
||||
<button type="submit" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-sm" [disabled]="busy()">
|
||||
<mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }}
|
||||
</button>
|
||||
</form>
|
||||
@if (registration()) {
|
||||
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
|
||||
No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a>
|
||||
</p>
|
||||
}
|
||||
</app-auth-frame>
|
||||
`
|
||||
})
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<app-auth-frame title="NEW MAILBOX" subtitle="Pick an address — you are logged in right away">
|
||||
@if (!loaded()) {
|
||||
<p class="text-slate-400 font-mono text-sm">Loading…</p>
|
||||
} @else if (!enabled()) {
|
||||
<p class="text-slate-300 font-rajdhani mb-4">Registration is closed on this server. Ask the administrator for a mailbox.</p>
|
||||
<a routerLink="/login" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-sm"><mat-icon>arrow_back</mat-icon> BACK TO LOGIN</a>
|
||||
} @else {
|
||||
<form (ngSubmit)="submit()" class="space-y-4">
|
||||
<div>
|
||||
<label class="synth-label" for="local">Address</label>
|
||||
<div class="flex items-stretch gap-2">
|
||||
<input id="local" name="local" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase())"
|
||||
required autocomplete="username" placeholder="name" aria-describedby="local-hint">
|
||||
<span class="flex items-center text-cyan-300 font-mono">@</span>
|
||||
@if (domains().length > 1) {
|
||||
<select name="domain" class="synth-input !w-auto" [ngModel]="domain()" (ngModelChange)="domain.set($event)"
|
||||
aria-label="Domain">
|
||||
@for (d of domains(); track d) { <option [value]="d">{{ d }}</option> }
|
||||
</select>
|
||||
} @else {
|
||||
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200">
|
||||
{{ domain() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p id="local-hint" class="mt-1 text-[11px] font-mono" [class]="localValid() || !local() ? 'text-slate-500' : 'text-pink-400'">
|
||||
Letters, digits, dot, dash, underscore
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="name">Your name (shown to recipients)</label>
|
||||
<input id="name" name="name" class="synth-input" [(ngModel)]="displayName" maxlength="100" autocomplete="name">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password">Password (8+ characters)</label>
|
||||
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="password2">Password again</label>
|
||||
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()"
|
||||
(ngModelChange)="password2.set($event)" required autocomplete="new-password">
|
||||
</div>
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
||||
<mat-icon class="!text-lg">error</mat-icon>{{ error() }}
|
||||
</p>
|
||||
}
|
||||
<button type="submit" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-sm" [disabled]="busy()">
|
||||
<mat-icon>person_add</mat-icon> {{ busy() ? 'CREATING…' : 'CREATE MAILBOX' }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
|
||||
Have a mailbox? <a routerLink="/login" class="text-cyan-300 hover:text-pink-400 font-semibold">Log in</a>
|
||||
</p>
|
||||
}
|
||||
</app-auth-frame>
|
||||
`
|
||||
})
|
||||
export class RegisterPage {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
readonly loaded = signal(false);
|
||||
readonly enabled = signal(false);
|
||||
readonly domains = signal<string[]>([]);
|
||||
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)); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="glass-panel static p-4 md:p-6" aria-labelledby="blocked-title">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<mat-icon class="text-pink-400">block</mat-icon>
|
||||
<div>
|
||||
<h1 id="blocked-title" class="text-xl font-orbitron font-bold neon-text-secondary">BLOCKED SENDERS</h1>
|
||||
<p class="text-xs text-slate-400 font-mono">Messages from these addresses go straight to Spam</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-wrap items-end gap-3 mb-5 p-4 rounded-xl bg-slate-950/50 border border-pink-500/20" (ngSubmit)="add()">
|
||||
<div class="flex-grow min-w-[220px]">
|
||||
<label class="synth-label" for="b-email">Sender address</label>
|
||||
<input id="b-email" name="email" type="email" class="synth-input" [(ngModel)]="email" placeholder="spammer@example.com">
|
||||
</div>
|
||||
<div class="flex-grow min-w-[180px]">
|
||||
<label class="synth-label" for="b-reason">Reason (optional)</label>
|
||||
<input id="b-reason" name="reason" class="synth-input" [(ngModel)]="reason" maxlength="255">
|
||||
</div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2.5 rounded-lg text-xs font-bold"><mat-icon>block</mat-icon> BLOCK</button>
|
||||
</form>
|
||||
|
||||
<jt-grid [rows]="entries()" [columns]="columns" [trackBy]="bySender" label="Blocked senders"
|
||||
[emptyMessage]="loading() ? 'Loading…' : 'Nobody is blocked'" [bulkActions]="bulkActions" (bulkAction)="onBulk($event)"
|
||||
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="senderOf" [initialSort]="{ key: 'date', direction: 'desc' }">
|
||||
<ng-template jtGridCell="sender" let-row><span class="font-mono text-sm text-cyan-300">{{ row.senderEmail }}</span></ng-template>
|
||||
<ng-template jtGridCell="reason" let-row><span class="text-slate-300">{{ row.reason || '' }}</span></ng-template>
|
||||
<ng-template jtGridCell="date" let-row><span class="font-mono text-xs text-slate-400">{{ row.createdAt | date: 'd MMM y, HH:mm' }}</span></ng-template>
|
||||
</jt-grid>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class BlockedPage {
|
||||
private api = inject(ApiService);
|
||||
private bulk = inject(JtGridBulkService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
readonly entries = signal<BlacklistEntry[]>([]);
|
||||
readonly loading = signal(true);
|
||||
email = '';
|
||||
reason = '';
|
||||
|
||||
readonly columns: JtGridColumn<BlacklistEntry>[] = [
|
||||
{ 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<BlacklistEntry>): 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<BlacklistEntry>): void {
|
||||
this.bulk.run({ rows: e.rows, label: 'Unblock', run: x => this.api.unblock(x.senderEmail) }).subscribe(() => this.load());
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="glass-panel static p-4 md:p-6" aria-labelledby="compose-title">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
|
||||
<h1 id="compose-title" class="text-xl font-orbitron font-bold neon-text-secondary">{{ title() }}</h1>
|
||||
<span class="text-xs font-mono" [class]="dirty() ? 'text-yellow-300' : 'text-slate-500'" aria-live="polite">
|
||||
{{ saving() ? 'Saving…' : dirty() ? 'Unsaved changes' : draftId() ? 'Draft saved' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form (ngSubmit)="send()" class="space-y-4">
|
||||
<div class="relative">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="synth-label" for="to">To</label>
|
||||
@if (!showCc()) {
|
||||
<button type="button" class="text-[11px] font-mono text-cyan-300 hover:text-pink-400 mb-1" (click)="showCc.set(true)">+ Cc</button>
|
||||
}
|
||||
</div>
|
||||
<input id="to" name="to" class="synth-input" [ngModel]="to()" (ngModelChange)="to.set($event); touch()"
|
||||
(focus)="focused.set('to')" (blur)="blurLater()" autocomplete="off" placeholder="name@domain, another@domain"
|
||||
role="combobox" aria-autocomplete="list" [attr.aria-expanded]="focused() === 'to' && suggestions().length > 0"
|
||||
aria-controls="suggest-to">
|
||||
@if (focused() === 'to') { <ng-container *ngTemplateOutlet="suggest; context: { field: 'to' }" /> }
|
||||
</div>
|
||||
@if (showCc()) {
|
||||
<div class="relative">
|
||||
<label class="synth-label" for="cc">Cc</label>
|
||||
<input id="cc" name="cc" class="synth-input" [ngModel]="cc()" (ngModelChange)="cc.set($event); touch()"
|
||||
(focus)="focused.set('cc')" (blur)="blurLater()" autocomplete="off"
|
||||
role="combobox" aria-autocomplete="list" [attr.aria-expanded]="focused() === 'cc' && suggestions().length > 0"
|
||||
aria-controls="suggest-cc">
|
||||
@if (focused() === 'cc') { <ng-container *ngTemplateOutlet="suggest; context: { field: 'cc' }" /> }
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<label class="synth-label" for="subject">Subject</label>
|
||||
<input id="subject" name="subject" class="synth-input" [ngModel]="subject()" (ngModelChange)="subject.set($event); touch()"
|
||||
maxlength="1000" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label class="synth-label" for="body">Message</label>
|
||||
<textarea id="body" name="body" rows="14" class="synth-input font-rajdhani leading-relaxed resize-y"
|
||||
[ngModel]="body()" (ngModelChange)="body.set($event); touch()"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="ghost-btn cursor-pointer" [class.opacity-50]="uploading()">
|
||||
<mat-icon>attach_file</mat-icon> Attach files
|
||||
<input type="file" multiple class="sr-only" (change)="attach($event)" [disabled]="uploading()">
|
||||
</label>
|
||||
<span class="text-[11px] font-mono text-slate-500">up to 10 MB each</span>
|
||||
@if (uploading()) { <span class="text-xs font-mono text-cyan-300">Uploading…</span> }
|
||||
</div>
|
||||
@if (attachments().length) {
|
||||
<ul class="flex flex-wrap gap-2 mt-3" aria-label="Attached files">
|
||||
@for (a of attachments(); track a.id) {
|
||||
<li class="flex items-center gap-2 pl-3 pr-1 py-1 rounded-lg bg-slate-900/70 border border-cyan-500/30 text-sm">
|
||||
<mat-icon class="!text-base text-cyan-300">attach_file</mat-icon>
|
||||
<span class="max-w-[220px] truncate text-slate-200">{{ a.filename }}</span>
|
||||
<span class="text-xs font-mono text-slate-500">{{ size(a.sizeBytes) }}</span>
|
||||
<button type="button" class="text-slate-400 hover:text-pink-400 p-1" [attr.aria-label]="'Remove ' + a.filename"
|
||||
(click)="detach(a)"><mat-icon class="!text-lg">close</mat-icon></button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert"><mat-icon class="!text-lg">error</mat-icon>{{ error() }}</p>
|
||||
}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-2 border-t border-cyan-500/20">
|
||||
<button type="submit" class="cyber-btn px-6 py-2.5 rounded-xl font-bold text-sm" [disabled]="sending() || uploading()">
|
||||
<mat-icon>send</mat-icon> {{ sending() ? 'SENDING…' : 'SEND' }}
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" (click)="saveNow()" [disabled]="saving() || !hasContent()">
|
||||
<mat-icon>save</mat-icon> Save draft
|
||||
</button>
|
||||
<span class="flex-grow"></span>
|
||||
<button type="button" class="ghost-btn danger" (click)="discard()"><mat-icon>delete</mat-icon> Discard</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<ng-template #suggest let-field="field">
|
||||
@if (suggestions().length) {
|
||||
<ul [id]="'suggest-' + field" role="listbox" aria-label="Contacts"
|
||||
class="absolute z-30 w-full mt-1 bg-slate-950/95 border border-pink-500/40 rounded-xl shadow-2xl py-1 backdrop-blur-2xl">
|
||||
@for (c of suggestions(); track c.email) {
|
||||
<li role="option" aria-selected="false" class="px-4 py-2 text-sm cursor-pointer hover:bg-cyan-500/20 flex gap-2"
|
||||
(mousedown)="$event.preventDefault(); pick(field, c)">
|
||||
<span class="text-slate-200 font-semibold">{{ c.name || c.email }}</span>
|
||||
@if (c.name) { <span class="text-cyan-300 font-mono text-xs self-center">{{ c.email }}</span> }
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</ng-template>
|
||||
`
|
||||
})
|
||||
export class ComposePage {
|
||||
readonly draft = input<string>();
|
||||
readonly reply = input<string>();
|
||||
readonly replyAll = input<string>();
|
||||
readonly forward = input<string>();
|
||||
readonly toParam = input<string>(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<number | null>(null);
|
||||
readonly attachments = signal<Attachment[]>([]);
|
||||
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<Contact[]>([]);
|
||||
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<number> {
|
||||
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');
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="glass-panel static p-4 md:p-6" aria-labelledby="contacts-title">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<mat-icon class="text-pink-400">contacts</mat-icon>
|
||||
<div>
|
||||
<h1 id="contacts-title" class="text-xl font-orbitron font-bold neon-text-secondary">CONTACTS</h1>
|
||||
<p class="text-xs text-slate-400 font-mono">{{ contacts().length }} contacts · suggested while you type an address</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" (click)="edit({ name: '', email: '' })">
|
||||
<mat-icon>person_add</mat-icon> ADD CONTACT
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<jt-grid [rows]="contacts()" [columns]="columns" [trackBy]="byEmail" label="Contacts"
|
||||
[emptyMessage]="loading() ? 'Loading…' : 'No contacts yet'" [bulkActions]="bulkActions" (bulkAction)="onBulk($event)"
|
||||
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="nameOf" [initialSort]="{ key: 'name', direction: 'asc' }">
|
||||
<ng-template jtGridCell="name" let-row><span class="font-semibold text-slate-100">{{ row.name || '—' }}</span></ng-template>
|
||||
<ng-template jtGridCell="email" let-row><span class="font-mono text-xs text-cyan-300">{{ row.email }}</span></ng-template>
|
||||
<ng-template jtGridCell="phone" let-row><span class="font-mono text-xs">{{ row.phone || '' }}</span></ng-template>
|
||||
<ng-template jtGridCell="company" let-row>{{ row.company || '' }}</ng-template>
|
||||
</jt-grid>
|
||||
</section>
|
||||
|
||||
@if (editing(); as c) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md" (click)="editing.set(null)"
|
||||
(keydown.escape)="editing.set(null)">
|
||||
<form class="glass-panel static max-w-lg w-full p-6 border-2 !border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)] space-y-4"
|
||||
role="dialog" aria-modal="true" aria-labelledby="contact-form-title" cdkTrapFocus [cdkTrapFocusAutoCapture]="true"
|
||||
(click)="$event.stopPropagation()" (ngSubmit)="save()">
|
||||
<h2 id="contact-form-title" class="text-lg font-orbitron font-bold text-slate-100">{{ c.id ? 'EDIT CONTACT' : 'NEW CONTACT' }}</h2>
|
||||
<div><label class="synth-label" for="c-name">Name</label>
|
||||
<input id="c-name" name="name" class="synth-input" [(ngModel)]="c.name" maxlength="255"></div>
|
||||
<div><label class="synth-label" for="c-email">Email *</label>
|
||||
<input id="c-email" name="email" type="email" class="synth-input" [(ngModel)]="c.email" required maxlength="255"></div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div><label class="synth-label" for="c-phone">Phone</label>
|
||||
<input id="c-phone" name="phone" class="synth-input" [(ngModel)]="c.phone" maxlength="50"></div>
|
||||
<div><label class="synth-label" for="c-company">Company</label>
|
||||
<input id="c-company" name="company" class="synth-input" [(ngModel)]="c.company" maxlength="255"></div>
|
||||
</div>
|
||||
@if (formError()) { <p class="text-sm text-pink-400" role="alert">{{ formError() }}</p> }
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="px-4 py-2 rounded-lg text-xs font-mono text-slate-400 hover:text-slate-200" (click)="editing.set(null)">CANCEL [ESC]</button>
|
||||
<button type="submit" class="cyber-btn px-5 py-2 rounded-lg text-xs font-bold">SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
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<Contact[]>([]);
|
||||
readonly loading = signal(true);
|
||||
readonly editing = signal<Contact | null>(null);
|
||||
readonly formError = signal('');
|
||||
|
||||
readonly columns: JtGridColumn<Contact>[] = [
|
||||
{ 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<Contact>): 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<Contact>): 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());
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<section class="glass-panel static p-4 md:p-6" [attr.aria-labelledby]="'folder-title'">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<mat-icon class="text-pink-400">{{ icon() }}</mat-icon>
|
||||
<div class="min-w-0">
|
||||
<h1 id="folder-title" class="text-xl font-orbitron font-bold neon-text-secondary truncate">{{ title() }}</h1>
|
||||
<p class="text-xs text-slate-400 font-mono">
|
||||
@if (q()) { Search “{{ q() }}”: {{ messages().length }} found }
|
||||
@else { {{ messages().length }} messages · {{ unread() }} unread }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (q()) {
|
||||
<a [routerLink]="['/folder', folderId()]" class="ghost-btn"><mat-icon>close</mat-icon> Clear search</a>
|
||||
}
|
||||
<button type="button" class="ghost-btn" (click)="load()" [disabled]="loading()" aria-label="Refresh" matTooltip="Refresh">
|
||||
<mat-icon [class.animate-spin]="loading()">refresh</mat-icon>
|
||||
</button>
|
||||
@if (canEmpty()) {
|
||||
<button type="button" class="ghost-btn danger" (click)="empty()" [disabled]="!messages().length">
|
||||
<mat-icon>delete_forever</mat-icon> Empty {{ title() }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<p class="text-pink-400 font-rajdhani mb-3" role="alert">{{ error() }}</p>
|
||||
}
|
||||
|
||||
<jt-grid [rows]="messages()" [columns]="columns()" [trackBy]="byId" [label]="title() + ' messages'"
|
||||
[emptyMessage]="loading() ? 'Loading…' : q() ? 'Nothing found' : 'No messages here'"
|
||||
[bulkActions]="bulkActions()" (bulkAction)="onBulk($event)"
|
||||
[rowActions]="rowActions" (rowAction)="onRow($event)" [rowSubject]="subjectOf"
|
||||
[highlight]="isUnread" [initialSort]="{ key: 'date', direction: 'desc' }" [pageSize]="25">
|
||||
<ng-template jtGridCell="flag" let-row>
|
||||
<button type="button" class="align-middle" [class]="row.flagged ? 'text-yellow-400' : 'text-slate-600 hover:text-yellow-300'"
|
||||
[attr.aria-label]="row.flagged ? 'Remove flag' : 'Flag'" [attr.aria-pressed]="row.flagged" (click)="toggleFlag(row)">
|
||||
<mat-icon class="!text-xl">{{ row.flagged ? 'star' : 'star_border' }}</mat-icon>
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="who" let-row>
|
||||
<div class="max-w-[220px] truncate" [class.font-bold]="!row.read" [class.text-white]="!row.read">
|
||||
{{ who(row) }}
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="subject" let-row>
|
||||
<a [routerLink]="link(row)" class="block max-w-[560px] group">
|
||||
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
|
||||
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
|
||||
{{ row.subject || '(no subject)' }}
|
||||
</span>
|
||||
<span class="truncate block text-xs text-slate-500">{{ row.body }}</span>
|
||||
</a>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="files" let-row>
|
||||
@if (row.attachmentCount) {
|
||||
<span class="text-cyan-300 text-xs font-mono whitespace-nowrap" [attr.aria-label]="row.attachmentCount + ' attachments'">
|
||||
<mat-icon class="!text-base">attach_file</mat-icon>{{ row.attachmentCount }}
|
||||
</span>
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="date" let-row>
|
||||
<span class="font-mono text-xs whitespace-nowrap text-slate-400" [title]="row.receivedAt | date: 'medium'">
|
||||
{{ shortDate(row.receivedAt) }}
|
||||
</span>
|
||||
</ng-template>
|
||||
<ng-template jtGridCell="size" let-row>
|
||||
<span class="font-mono text-xs text-slate-500 whitespace-nowrap">{{ size(row) }}</span>
|
||||
</ng-template>
|
||||
</jt-grid>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class FolderPage {
|
||||
readonly id = input.required<string>();
|
||||
readonly q = input<string | undefined>();
|
||||
|
||||
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<Message[]>([]);
|
||||
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<JtGridColumn<Message>[]>(() => [
|
||||
{ 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<RowAction[]>(() => {
|
||||
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<Message>): 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<Message>): 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<unknown>, 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();
|
||||
}
|
||||
}
|
||||
@@ -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: `<section class="glass-panel static p-6 text-slate-400 font-mono text-sm">{{ message }}</section>`
|
||||
})
|
||||
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); }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
<section class="glass-panel static p-6">
|
||||
<p class="text-pink-400 font-rajdhani mb-4" role="alert">{{ error() }}</p>
|
||||
<a routerLink="/" class="ghost-btn"><mat-icon>arrow_back</mat-icon> Inbox</a>
|
||||
</section>
|
||||
} @else if (message(); as m) {
|
||||
<article class="glass-panel static p-4 md:p-6" aria-labelledby="message-subject">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-5 pb-4 border-b border-cyan-500/20" role="toolbar" aria-label="Message actions">
|
||||
<button type="button" class="ghost-btn" (click)="back()"><mat-icon>arrow_back</mat-icon> Back</button>
|
||||
<span class="flex-grow"></span>
|
||||
<a [routerLink]="['/compose', { reply: m.id }]" class="ghost-btn"><mat-icon>reply</mat-icon> Reply</a>
|
||||
@if (hasOthers()) {
|
||||
<a [routerLink]="['/compose', { replyAll: m.id }]" class="ghost-btn"><mat-icon>reply_all</mat-icon> Reply all</a>
|
||||
}
|
||||
<a [routerLink]="['/compose', { forward: m.id }]" class="ghost-btn"><mat-icon>forward</mat-icon> Forward</a>
|
||||
<button type="button" class="ghost-btn" [attr.aria-pressed]="m.flagged" (click)="update({ flagged: !m.flagged })">
|
||||
<mat-icon [class.text-yellow-400]="m.flagged">{{ m.flagged ? 'star' : 'star_border' }}</mat-icon> {{ m.flagged ? 'Flagged' : 'Flag' }}
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" (click)="markUnread()"><mat-icon>mark_email_unread</mat-icon> Unread</button>
|
||||
<label for="move-to" class="sr-only">Move to folder</label>
|
||||
<select id="move-to" class="bg-slate-900/80 text-slate-300 border border-cyan-500/35 rounded-[10px] px-2 py-1.5 text-xs font-mono"
|
||||
[ngModel]="''" (ngModelChange)="move($event)">
|
||||
<option value="" disabled>Move to…</option>
|
||||
@for (f of targets(); track f.id) { <option [value]="f.id">{{ label(f) }}</option> }
|
||||
</select>
|
||||
@if (inSpam()) {
|
||||
<button type="button" class="ghost-btn" (click)="notSpam()"><mat-icon>inbox</mat-icon> Not spam</button>
|
||||
} @else if (!outgoing()) {
|
||||
<button type="button" class="ghost-btn" (click)="spam()"><mat-icon>report</mat-icon> Spam</button>
|
||||
}
|
||||
<button type="button" class="ghost-btn danger" (click)="remove()"><mat-icon>delete</mat-icon> {{ inTrash() ? 'Delete forever' : 'Delete' }}</button>
|
||||
</div>
|
||||
|
||||
<h1 id="message-subject" class="text-2xl font-orbitron font-bold neon-text-secondary mb-4 break-words">{{ m.subject || '(no subject)' }}</h1>
|
||||
|
||||
<div class="flex items-start gap-3 mb-5">
|
||||
<div class="w-11 h-11 rounded-full bg-gradient-to-br from-pink-500/60 to-cyan-400/60 flex items-center justify-center font-orbitron font-bold text-white shrink-0"
|
||||
aria-hidden="true">{{ initial() }}</div>
|
||||
<dl class="text-sm font-rajdhani min-w-0 flex-grow grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5">
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">From</dt>
|
||||
<dd class="text-slate-100 break-all">
|
||||
@if (m.senderName) { <span class="font-bold">{{ m.senderName }}</span> }
|
||||
<span class="text-cyan-300 font-mono text-xs ml-1"><{{ m.senderEmail }}></span>
|
||||
@if (!outgoing()) {
|
||||
<button type="button" class="ml-2 text-[11px] font-mono text-slate-500 hover:text-pink-400" (click)="block()">block sender</button>
|
||||
}
|
||||
</dd>
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">To</dt>
|
||||
<dd class="text-slate-300 break-all">{{ m.recipientEmail }}</dd>
|
||||
@if (m.ccEmail) {
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Cc</dt>
|
||||
<dd class="text-slate-300 break-all">{{ m.ccEmail }}</dd>
|
||||
}
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt>
|
||||
<dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@if (m.spam) {
|
||||
<p class="mb-4 px-3 py-2 rounded-lg bg-pink-500/15 border border-pink-500/40 text-pink-200 text-sm font-rajdhani flex items-center gap-2">
|
||||
<mat-icon>report</mat-icon> Marked as spam: the sender is blocked or the message failed the SPF/DKIM/DMARC checks.
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (m.html) {
|
||||
<div class="flex items-center gap-3 mb-2 text-xs font-mono text-slate-400">
|
||||
<span>HTML message</span>
|
||||
<button type="button" class="ghost-btn !py-1" (click)="remoteImages.set(!remoteImages())" [attr.aria-pressed]="remoteImages()">
|
||||
<mat-icon>image</mat-icon> {{ remoteImages() ? 'Hide remote images' : 'Show remote images' }}
|
||||
</button>
|
||||
</div>
|
||||
<iframe class="w-full h-[65vh] rounded-xl bg-white border border-cyan-500/30" sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
[srcdoc]="htmlDoc()" title="Message body"></iframe>
|
||||
} @else {
|
||||
<div class="bg-slate-950/70 rounded-xl border border-cyan-500/20 p-4 md:p-5 font-rajdhani text-[1.02rem] text-slate-200 whitespace-pre-wrap break-words leading-relaxed">{{ m.body }}</div>
|
||||
}
|
||||
|
||||
@if (attachments().length) {
|
||||
<h2 class="mt-6 mb-2 text-xs font-orbitron font-bold text-cyan-300 tracking-widest">ATTACHMENTS ({{ attachments().length }})</h2>
|
||||
<ul class="flex flex-wrap gap-2">
|
||||
@for (a of attachments(); track a.id) {
|
||||
<li>
|
||||
<button type="button" class="ghost-btn !py-2" (click)="download(a)" [attr.aria-label]="'Download ' + a.filename">
|
||||
<mat-icon>{{ fileIcon(a) }}</mat-icon>
|
||||
<span class="max-w-[220px] truncate text-slate-200">{{ a.filename }}</span>
|
||||
<span class="text-slate-500">{{ size(a.sizeBytes) }}</span>
|
||||
<mat-icon class="text-cyan-300">download</mat-icon>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</article>
|
||||
} @else {
|
||||
<section class="glass-panel static p-6 text-slate-400 font-mono text-sm">Loading…</section>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class MessagePage {
|
||||
readonly id = input.required<string>();
|
||||
|
||||
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<Message | null>(null);
|
||||
readonly attachments = signal<Attachment[]>([]);
|
||||
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<SafeHtml>(() => {
|
||||
const img = this.remoteImages() ? 'img-src data: https: http:;' : 'img-src data:;';
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; ${img} font-src data:">`;
|
||||
const base = '<base target="_blank"><style>body{font-family:system-ui,sans-serif;margin:16px;color:#111;word-wrap:break-word}</style>';
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="profile-title">
|
||||
<h1 id="profile-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">badge</mat-icon> PROFILE
|
||||
</h1>
|
||||
@if (state.profile(); as p) {
|
||||
<dl class="text-sm font-rajdhani grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 mb-5">
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Mailbox</dt><dd class="text-cyan-300 font-mono">{{ p.email }}</dd>
|
||||
<dt class="text-slate-500 font-mono text-xs pt-0.5">Since</dt><dd class="text-slate-300">{{ p.createdAt | date: 'd MMMM y' }}</dd>
|
||||
</dl>
|
||||
}
|
||||
<form (ngSubmit)="saveName()" class="space-y-3">
|
||||
<div>
|
||||
<label class="synth-label" for="display-name">Name shown to recipients</label>
|
||||
<input id="display-name" name="displayName" class="synth-input" [(ngModel)]="displayName" maxlength="100" required>
|
||||
</div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>save</mat-icon> SAVE NAME</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="password-title">
|
||||
<h2 id="password-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">key</mat-icon> PASSWORD
|
||||
</h2>
|
||||
<form (ngSubmit)="changePassword()" class="space-y-3">
|
||||
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||
<div><label class="synth-label" for="pw-current">Current password</label>
|
||||
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div>
|
||||
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
|
||||
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div>
|
||||
<div><label class="synth-label" for="pw-again">New password again</label>
|
||||
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div>
|
||||
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="storage-title">
|
||||
<h2 id="storage-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">storage</mat-icon> STORAGE
|
||||
</h2>
|
||||
<div class="flex justify-between text-xs font-mono text-slate-300 mb-1">
|
||||
<span>{{ used() }} of {{ quota() }}</span><span class="text-pink-400">{{ percent() }}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-slate-800 overflow-hidden mb-4" role="progressbar" aria-label="Storage used"
|
||||
[attr.aria-valuenow]="percent()" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="h-full bg-gradient-to-r from-cyan-400 to-pink-500" [style.width.%]="percent()"></div>
|
||||
</div>
|
||||
<ul class="text-sm font-rajdhani text-slate-300 space-y-1">
|
||||
@for (f of state.folders(); track f.id) {
|
||||
<li class="flex justify-between"><span>{{ f.system ? label(f.name) : f.name }}</span>
|
||||
<span class="font-mono text-xs text-slate-400">{{ f.messageCount }} messages</span></li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="look-title">
|
||||
<h2 id="look-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">palette</mat-icon> LOOK
|
||||
</h2>
|
||||
<fieldset class="mb-4">
|
||||
<legend class="synth-label">Palette</legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@for (t of themes; track t.value) {
|
||||
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value"
|
||||
[class.!border-pink-500]="theme.theme() === t.value" [class.!text-pink-300]="theme.theme() === t.value"
|
||||
(click)="theme.theme.set(t.value)">{{ t.label }}</button>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-pink-500/20">
|
||||
<div><div class="font-bold text-slate-200">High contrast</div><div class="text-xs text-slate-400">Black background, bright outlines</div></div>
|
||||
<button type="button" class="ghost-btn" [attr.aria-pressed]="theme.highContrast()" (click)="theme.highContrast.set(!theme.highContrast())">
|
||||
{{ theme.highContrast() ? 'ON' : 'OFF' }}</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-pink-500/20">
|
||||
<div><div class="font-bold text-slate-200">Grid motion</div><div class="text-xs text-slate-400">The moving retro floor in the background</div></div>
|
||||
<button type="button" class="ghost-btn" [attr.aria-pressed]="theme.animation()" (click)="theme.animation.set(!theme.animation())">
|
||||
{{ theme.animation() ? 'ON' : 'OFF' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
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<string, string>)[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))
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<app-background />
|
||||
<div class="relative z-10 flex flex-col min-h-screen">
|
||||
<header class="px-4 py-3 lg:px-6 border-b border-cyan-500/20 glass-panel static rounded-none border-t-0 border-x-0 sticky top-0 z-40 backdrop-blur-xl">
|
||||
<div class="max-w-[1500px] mx-auto flex items-center gap-3">
|
||||
<button type="button" class="lg:hidden ghost-btn !px-2" aria-label="Folders" [attr.aria-expanded]="menuOpen()"
|
||||
(click)="menuOpen.set(!menuOpen())"><mat-icon>menu</mat-icon></button>
|
||||
<a routerLink="/" class="flex items-center gap-3 shrink-0" aria-label="Synth Mail — inbox">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500 to-cyan-400 flex items-center justify-center
|
||||
text-slate-950 font-black text-xl shadow-[0_0_15px_rgba(255,0,127,0.8)] font-orbitron">M</div>
|
||||
<div class="hidden sm:block">
|
||||
<div class="text-xl font-extrabold font-orbitron tracking-wider neon-text-primary leading-tight">
|
||||
SYNTH <span class="text-cyan-400 font-normal">MAIL</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-cyan-300/70 font-mono tracking-widest uppercase">Reactive mail server</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<form class="flex-grow max-w-xl mx-auto" role="search" (ngSubmit)="search()">
|
||||
<label for="global-search" class="sr-only">Search in the current folder</label>
|
||||
<div class="relative">
|
||||
<mat-icon class="absolute left-3 top-1/2 -translate-y-1/2 text-cyan-400/70 !text-xl">search</mat-icon>
|
||||
<input id="global-search" name="q" [(ngModel)]="query" placeholder="Search mail…"
|
||||
class="synth-input !pl-10 !rounded-full !py-2">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="hidden md:flex items-center gap-3 bg-slate-950/60 px-4 py-1.5 rounded-full border border-pink-500/30 text-xs font-mono">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-ping" aria-hidden="true"></span>
|
||||
<span class="text-slate-300">Unread: <span class="text-pink-400 font-bold">{{ state.unreadTotal() }}</span></span>
|
||||
<span class="text-slate-600" aria-hidden="true">|</span>
|
||||
<span class="text-cyan-300 max-w-[220px] truncate" [matTooltip]="auth.email() ?? ''">{{ state.profile()?.displayName || auth.email() }}</span>
|
||||
</div>
|
||||
|
||||
<label for="palette" class="sr-only">Color theme</label>
|
||||
<select id="palette" class="hidden xl:block bg-slate-900/90 text-cyan-300 border border-cyan-500/40 rounded-lg px-2 py-1.5 text-xs font-mono"
|
||||
[ngModel]="theme.theme()" (ngModelChange)="theme.theme.set($event)">
|
||||
@for (t of themes; track t.value) { <option [value]="t.value">{{ t.label }}</option> }
|
||||
</select>
|
||||
<button type="button" class="ghost-btn !px-2" matTooltip="High contrast" aria-label="High contrast"
|
||||
[attr.aria-pressed]="theme.highContrast()" (click)="theme.highContrast.set(!theme.highContrast())">
|
||||
<mat-icon class="text-yellow-400">contrast</mat-icon>
|
||||
</button>
|
||||
<button type="button" class="ghost-btn !px-2" matTooltip="Log out" aria-label="Log out" (click)="logout()">
|
||||
<mat-icon>logout</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
|
||||
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
|
||||
aria-label="Mail folders">
|
||||
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
|
||||
<mat-icon>edit</mat-icon> COMPOSE
|
||||
</a>
|
||||
|
||||
<ul class="space-y-1">
|
||||
@for (folder of systemFolders(); track folder.id) {
|
||||
<li>
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
|
||||
<span class="flex-grow">{{ label(folder) }}</span>
|
||||
@if (badge(folder)) {
|
||||
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300 border border-pink-500/40"
|
||||
[attr.aria-label]="badge(folder) + ' unread'">{{ badge(folder) }}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="flex items-center justify-between mt-5 mb-2 px-1">
|
||||
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MY FOLDERS</h2>
|
||||
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="New folder" aria-label="New folder"
|
||||
(click)="createFolder()"><mat-icon class="!text-xl">create_new_folder</mat-icon></button>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
@for (folder of ownFolders(); track folder.id) {
|
||||
<li class="flex items-center gap-1">
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40"
|
||||
class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">folder</mat-icon>
|
||||
<span class="flex-grow truncate">{{ folder.name }}</span>
|
||||
@if (folder.unreadCount) {
|
||||
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300">{{ folder.unreadCount }}</span>
|
||||
}
|
||||
</a>
|
||||
<jt-row-actions [actions]="folderActions" [subject]="folder.name" (action)="onFolderAction($event, folder)" />
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="px-3 text-xs text-slate-500 font-mono">No folders yet</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
|
||||
@for (link of links; track link.path) {
|
||||
<a [routerLink]="link.path" routerLinkActive="!text-pink-300"
|
||||
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (state.profile(); as p) {
|
||||
<div class="mt-4 px-1 text-[11px] font-mono text-slate-400">
|
||||
<div class="flex justify-between mb-1"><span>Storage</span><span class="text-cyan-300">{{ used() }}</span></div>
|
||||
<div class="h-1.5 rounded-full bg-slate-800 overflow-hidden" role="progressbar" aria-label="Storage used"
|
||||
[attr.aria-valuenow]="usedPercent()" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="h-full bg-gradient-to-r from-cyan-400 to-pink-500" [style.width.%]="usedPercent()"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<main class="min-w-0" id="main">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 = 'http://localhost:8080/api';
|
||||
|
||||
private currentUserSubject = new BehaviorSubject<string | null>(
|
||||
localStorage.getItem('email')
|
||||
);
|
||||
currentUser$ = this.currentUserSubject.asObservable();
|
||||
|
||||
private tokenSubject = new BehaviorSubject<string | null>(
|
||||
localStorage.getItem('token')
|
||||
);
|
||||
token$ = this.tokenSubject.asObservable();
|
||||
|
||||
login({ email, password }: LoginRequest): Observable<LoginResponse> {
|
||||
return this.http.post<LoginResponse>(`${this.apiUrl}/login`, { email, password }).pipe(
|
||||
tap(({ token, email }) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('email', email);
|
||||
this.tokenSubject.next(token);
|
||||
this.currentUserSubject.next(email);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
register({ email, password, displayName }: RegisterRequest): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/register`, { email, password, displayName });
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('email');
|
||||
this.tokenSubject.next(null);
|
||||
this.currentUserSubject.next(null);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
isLoggedIn(): boolean {
|
||||
return !!localStorage.getItem('token');
|
||||
}
|
||||
|
||||
getEmail(): string | null {
|
||||
return localStorage.getItem('email');
|
||||
}
|
||||
}
|
||||
@@ -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 = 'http://localhost:8080/api';
|
||||
|
||||
// Папки
|
||||
getFolders(): Observable<Folder[]> {
|
||||
return this.http.get<Folder[]>(`${this.apiUrl}/folders`);
|
||||
}
|
||||
|
||||
// Письма
|
||||
getMessages(folderId: number, page: number = 0, size: number = 20): Observable<PageResponse<Message>> {
|
||||
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages`, {
|
||||
params: { folderId, page, size }
|
||||
});
|
||||
}
|
||||
|
||||
getMessage(id: number): Observable<Message> {
|
||||
return this.http.get<Message>(`${this.apiUrl}/messages/${id}`);
|
||||
}
|
||||
|
||||
searchMessages(
|
||||
folderId: number,
|
||||
keyword?: string,
|
||||
subject?: string,
|
||||
body?: string,
|
||||
sender?: string,
|
||||
recipient?: string
|
||||
): Observable<Message[]> {
|
||||
const params: { [key: string]: string } = { folderId: folderId.toString() };
|
||||
if (keyword) params['keyword'] = keyword;
|
||||
if (subject) params['subject'] = subject;
|
||||
if (body) params['body'] = body;
|
||||
if (sender) params['sender'] = sender;
|
||||
if (recipient) params['recipient'] = recipient;
|
||||
return this.http.get<PageResponse<Message>>(`${this.apiUrl}/messages/search`, { params })
|
||||
.pipe(map(response => response.content));
|
||||
}
|
||||
|
||||
// Отправка
|
||||
send(sendRequest: SendRequest): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/send`, sendRequest);
|
||||
}
|
||||
|
||||
// Статус
|
||||
markAsRead(id: number): Observable<void> {
|
||||
return this.http.put<void>(`${this.apiUrl}/messages/${id}/read`, null);
|
||||
}
|
||||
|
||||
deleteMessage(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/messages/${id}`);
|
||||
}
|
||||
|
||||
// Вложения
|
||||
getAttachments(messageId: number): Observable<Attachment[]> {
|
||||
return this.http.get<Attachment[]>(`${this.apiUrl}/messages/${messageId}/attachments`);
|
||||
}
|
||||
|
||||
uploadAttachment(messageId: number, file: File): Observable<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.http.post<void>(`${this.apiUrl}/messages/${messageId}/attachments`, formData);
|
||||
}
|
||||
|
||||
downloadAttachment(attachmentId: number): Observable<Blob> {
|
||||
return this.http.get<Blob>(`${this.apiUrl}/attachments/${attachmentId}/download`, {
|
||||
responseType: 'blob' as 'json'
|
||||
});
|
||||
}
|
||||
|
||||
deleteAttachment(attachmentId: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/attachments/${attachmentId}`);
|
||||
}
|
||||
|
||||
// Чёрный список
|
||||
getBlacklist(): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${this.apiUrl}/spam/blacklist`);
|
||||
}
|
||||
|
||||
addToBlacklist(entry: BlacklistEntry): Observable<void> {
|
||||
return this.http.post<void>(`${this.apiUrl}/spam/blacklist`, entry);
|
||||
}
|
||||
|
||||
removeFromBlacklist(senderEmail: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/spam/blacklist`, {
|
||||
params: { senderEmail }
|
||||
});
|
||||
}
|
||||
|
||||
// Контакты
|
||||
getContacts(): Observable<Contact[]> {
|
||||
return this.http.get<Contact[]>(`${this.apiUrl}/contacts`);
|
||||
}
|
||||
|
||||
addContact(contact: Contact): Observable<Contact> {
|
||||
return this.http.post<Contact>(`${this.apiUrl}/contacts`, contact);
|
||||
}
|
||||
|
||||
updateContact(id: number, contact: Contact): Observable<Contact> {
|
||||
return this.http.put<Contact>(`${this.apiUrl}/contacts/${id}`, contact);
|
||||
}
|
||||
|
||||
deleteContact(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/contacts/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="synth-stage" aria-hidden="true">
|
||||
<div class="synth-sun"></div>
|
||||
<div class="synth-mountains"></div>
|
||||
<div class="grid-container"><div class="grid-plane"></div></div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class BackgroundComponent {}
|
||||
@@ -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<OpenDialog | null>(null);
|
||||
|
||||
ask(request: DialogRequest): Observable<string | null> {
|
||||
return new Observable<string | null>(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<string | null> {
|
||||
return this.ask({ title, message, confirmLabel, danger });
|
||||
}
|
||||
|
||||
prompt(title: string, inputLabel: string, value = '', confirmLabel = 'Save'): Observable<string | null> {
|
||||
return this.ask({ title, inputLabel, input: value, confirmLabel, required: true });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}];
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 w-[min(380px,calc(100vw-2rem))]">
|
||||
@for (toast of toasts.toasts(); track toast.id) {
|
||||
<div class="glass-panel static px-4 py-3 flex items-start gap-3 text-sm font-rajdhani"
|
||||
[class.border-pink-500]="toast.kind === 'error'" role="status">
|
||||
<mat-icon [class]="toast.kind === 'error' ? 'text-pink-400' : 'text-emerald-400'">
|
||||
{{ toast.kind === 'error' ? 'error' : 'check_circle' }}
|
||||
</mat-icon>
|
||||
<span class="flex-grow text-slate-200">{{ toast.text }}</span>
|
||||
<button type="button" class="text-slate-400 hover:text-pink-400" aria-label="Dismiss" (click)="toasts.dismiss(toast.id)">
|
||||
<mat-icon class="!text-lg">close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (dialogs.current(); as d) {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md"
|
||||
(click)="cancel()" (keydown.escape)="cancel()">
|
||||
<div class="glass-panel static max-w-lg w-full p-6 relative border-2 !border-cyan-400 shadow-[0_0_50px_rgba(0,240,255,0.4)]"
|
||||
role="dialog" aria-modal="true" aria-labelledby="dialog-title" cdkTrapFocus [cdkTrapFocusAutoCapture]="true"
|
||||
(click)="$event.stopPropagation()">
|
||||
<button type="button" (click)="cancel()" aria-label="Close dialog"
|
||||
class="absolute top-4 right-4 text-slate-400 hover:text-pink-400 rounded-lg p-1">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center border"
|
||||
[class]="d.danger ? 'bg-pink-500/20 border-pink-500 text-pink-400' : 'bg-cyan-500/15 border-cyan-400 text-cyan-300'">
|
||||
<mat-icon>{{ d.danger ? 'warning' : d.input !== undefined ? 'edit' : 'help' }}</mat-icon>
|
||||
</div>
|
||||
<h3 id="dialog-title" class="text-lg font-orbitron font-bold text-slate-100 pr-8">{{ d.title }}</h3>
|
||||
</div>
|
||||
@if (d.message) {
|
||||
<p class="font-rajdhani text-slate-300 mb-4 whitespace-pre-line">{{ d.message }}</p>
|
||||
}
|
||||
<form (ngSubmit)="confirm()">
|
||||
@if (d.input !== undefined) {
|
||||
<label class="synth-label" for="dialog-input">{{ d.inputLabel }}</label>
|
||||
<input #input id="dialog-input" name="value" class="synth-input mb-5" [(ngModel)]="value" autocomplete="off">
|
||||
}
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" (click)="cancel()" class="px-4 py-2 rounded-lg text-xs font-mono text-slate-400 hover:text-slate-200">
|
||||
CANCEL [ESC]
|
||||
</button>
|
||||
<button type="submit" class="cyber-btn px-5 py-2 rounded-lg text-xs font-bold"
|
||||
[class.!border-pink-500]="d.danger" [disabled]="d.required && !value().trim()">
|
||||
{{ d.confirmLabel || 'Confirm' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`
|
||||
})
|
||||
export class OverlaysComponent {
|
||||
readonly toasts = inject(ToastService);
|
||||
readonly dialogs = inject(DialogService);
|
||||
readonly value = signal('');
|
||||
private input = viewChild<ElementRef<HTMLInputElement>>('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); }
|
||||
}
|
||||
@@ -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 <html>, 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 */ }
|
||||
}
|
||||
@@ -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<Toast[]>([]);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="theme-cyber-magenta">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>MailServer — Synthwave Edition</title>
|
||||
<title>Synth Mail</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<!-- FontAwesome Icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-synth-dark">
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user