Compare commits

...
Author SHA1 Message Date
Liosha84 87c6e5325d Merge pull request 'Пароли новых ящиков — ещё и в лог (видно в Jambotron без консоли)' (#11) from feat/passwords-in-log into master
Reviewed-on: #11
2026-11-05 22:55:42 +01:00
Liosha84 152e93d997 Пароли новых ящиков — ещё и в лог: видно в Jambotron без консоли
После передеплоя (новая база) владелец видит пароли admin@ и trap@ в
проект → Сервисы → mail → Логи; файл /app/data/initial-passwords.txt остаётся.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:34:48 +01:00
Liosha84 eaffffd4cc Merge pull request '75.2 IMAP: новый сервер на Reactor Netty (STARTTLS, 993, IDLE, UIDPLUS, MOVE)' (#10) from feat/75-2-imap into master
Reviewed-on: #10
2026-11-05 20:59:35 +01:00
Liosha84 b72be91a8b 75.2 IMAP: новый сервер на Reactor Netty (STARTTLS, 993, IDLE, UIDPLUS, MOVE)
Старый IMAP (выключен, .block() на сетевом потоке) удалён, написан заново:
- ImapServer: декодер целых команд с литералами ({n} после "+ Ready", {n+}),
  команды по одной на рабочем потоке; STARTTLS (без выполнения открытого текста
  после него) и неявный TLS; IDLE с опросом раз в 5 с до DONE
- ImapSession: IMAP4rev1 + LITERAL+, SASL-IR, IDLE, UIDPLUS, MOVE, UNSELECT,
  NAMESPACE, SPECIAL-USE, ID; FETCH (ENVELOPE, BODYSTRUCTURE, BODY[section]<partial>),
  SEARCH, STORE, COPY/MOVE с COPYUID, APPEND с APPENDUID, EXPUNGE/UID EXPUNGE
- MimeTree: части письма по байтам — размеры BODYSTRUCTURE совпадают с BODY[]
- V4: UID на папку выдаёт триггер (перенос из веба тоже получает новый UID),
  флаги \Answered/\Deleted, message_sources — письмо как пришло (SMTP, APPEND)
  или собранное один раз (MimeSource); правка черновика сбрасывает его
- тесты: настоящий клиент Angus IMAP, сырые литералы/IDLE/подмешивание (47 всего)
- живая проверка: обновление V3 -> V4 на PostgreSQL с письмами, Python imaplib:
  STARTTLS, 993, папки, вложения, поиск, APPEND/COPY/MOVE/EXPUNGE, IDLE — 0 ошибок

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:45:06 +01:00
Liosha84 73c03b1d6d Merge pull request '75.1 TLS для SMTP: STARTTLS и неявный TLS (465)' (#9) from feat/75-1-smtp-starttls into master
Reviewed-on: #9
2026-11-05 19:42:52 +01:00
Liosha84 17d489e400 75.1 TLS для SMTP: STARTTLS и неявный TLS (465)
- SmtpServer: STARTTLS (RFC 3207) — SslHandler ставится сразу после того,
  как ответ 220 ушёл открытым текстом; сессия начинается заново; строки,
  присланные открытым текстом вместе со STARTTLS, не выполняются, соединение
  закрывается (CVE-2011-0411); свежий декодер строк без остатков открытого текста
- неявный TLS на SMTPS_PORT (RFC 8314); AUTH — только внутри TLS
  (или SMTP_AUTH_WITHOUT_TLS); Received: ESMTPS/ESMTPSA
- SmtpTls: свой сертификат (MAIL_TLS_CERT/KEY) или самоподписанный,
  создаётся один раз в MAIL_TLS_DIR (в образе /app/data/tls); TLS 1.2/1.3
- тесты: STARTTLS + AUTH, подмешанный открытый текст, JavaMail со
  starttls.required, порт 465, переиспользование сертификата (38 всего)
- проверено вживую: openssl s_client -starttls smtp и порт 465, TLSv1.3

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:42:10 +01:00
Liosha84 37f6b3ef3d Merge pull request '75.4 Ловушка почты: catch-all только для адресов без своего ящика, файл с паролями' (#8) from feat/75-4-mail-trap into master
Reviewed-on: #8
2026-11-05 18:14:23 +01:00
Liosha84andClaude Opus 5.5 69b62b5769 README: SMTP ловушки для staging — через внутренний TCP-порт Jambotron (#781)
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:10:38 +01:00
Liosha84 12c2223c15 75.4 Ловушка почты: catch-all только для адресов без своего ящика, файл с паролями
- MailRouter: существующий ящик получает свою почту, всё остальное (и чужие домены) —
  в ящик MAIL_CATCH_ALL; раньше ловушка забирала и письма между ящиками стенда
- DataInitializer: ящик-ловушка создаётся при старте; случайные пароли — в
  MAIL_PASSWORDS_FILE (права 600, в образе /app/data/initial-passwords.txt)
- docker-compose.jambotron.yml: MAIL_CATCH_ALL=trap@mail.test по умолчанию
- README: ловушка, файл паролей, что нужно для staging Jambotron (TCP-проброс SMTP)
- тесты: MailRouterTest, DataInitializerTest (33 всего)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:43:49 +01:00
Liosha84 db0249eb74 Merge pull request 'Почтовый кабинет: регистрация, папки, черновики, вложения, грид, synthwave-интерфейс' (#7) from feat/mail-cabinet into master
Reviewed-on: #7
2026-11-05 16:43:54 +01:00
Liosha84 29b1beabe0 Почтовый кабинет: регистрация, папки, черновики, вложения, грид @jambotron/aria-grid, интерфейс по synthwave-шаблону
Бэкенд: регистрация со входом сразу и /api/config, профиль и смена пароля, свои папки, PATCH письма
(прочитано, флажок, перенос; Спам ставит отметку), корзина и очистка, черновики, несколько адресатов и копия,
вложения черновика уходят с письмом, вложения и заголовки To/Cc входящих сохраняются, письма заблокированных —
в Спам. Исправлено: загрузка файла (415), колонка content_id (500 на любых вложениях), SPA-маршрут с точкой.
Фронтенд переписан: все таблицы — грид, оформление — шаблон synthwave-design-showcase.html.

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:41:29 +01:00
Liosha84 f605fbfd85 Merge pull request 'Вход из браузера: X-Forwarded-* — CORS 403 за Caddy' (#6) from fix/forwarded-headers into master
Reviewed-on: #6
2026-11-05 15:44:28 +01:00
Liosha84 be6fed4986 Вход из браузера: учитывать X-Forwarded-* — за Caddy свой домен считался чужим для CORS (403)
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:43:10 +01:00
Liosha84 44e2ced266 Merge pull request 'Вход без всплывающего окна браузера (Basic) — на форму входа при 401' (#5) from fix/no-basic-auth-popup into master
Reviewed-on: #5
2026-11-05 15:34:40 +01:00
Liosha84 e54e140046 Merge pull request 'Dockerfile без Alpine — curl в healthcheck падал на ВМ staging' (#4) from fix/no-alpine-runtime into master
Reviewed-on: #4
2026-11-05 15:34:32 +01:00
Liosha84 e1427c062c Вход: без всплывающего окна браузера — API без токена отвечает 401 без Basic, интерфейс уводит на форму входа
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:33:58 +01:00
Liosha84 fdd6b8a51f Dockerfile без Alpine: на ВМ staging бинарники Alpine 3.24.3 падают (Bus error), curl в healthcheck тоже
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:36:58 +01:00
Liosha84 f1cf20f439 Merge pull request 'compose для Jambotron: postgres:17 (Debian) вместо -alpine' (#3) from fix/postgres-debian into master
Reviewed-on: #3
2026-11-05 04:07:23 +01:00
93 changed files with 7511 additions and 2379 deletions
+50 -8
View File
@@ -13,13 +13,15 @@
``` ```
- **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке. - **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется.
- **Не открытый релей.** - **Не открытый релей.**
- Письмо на свой домен принимается от кого угодно. - Письмо на свой домен принимается от кого угодно.
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`. - Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
- Вошедший пользователь пишет только от своего адреса, иначе `553`. - Вошедший пользователь пишет только от своего адреса, иначе `553`.
- **Маршрут** (`MailRouter`) один для SMTP и веб-почты: - **Маршрут** (`MailRouter`) один для SMTP и веб-почты:
- наш домен → наш ящик, а если ящика нет — «нет такого пользователя»; - есть такой ящик в нашем домене → в него;
- задан `MAIL_CATCH_ALL` → всё в этот ящик; - задан `MAIL_CATCH_ALL` → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
- наш домен без такого ящика → «нет такого пользователя»;
- чужой домен → очередь. - чужой домен → очередь.
- **Отправка наружу** (`OUTBOUND_MODE`): - **Отправка наружу** (`OUTBOUND_MODE`):
- `off` — ничего не уходит; - `off` — ничего не уходит;
@@ -29,6 +31,30 @@
- **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`. - **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`.
- **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах. - **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах.
## IMAP
Почтовые программы (Thunderbird, почта на телефоне) работают с теми же ящиками, что и веб-почта.
- **Порт** `IMAP_PORT` (3143) со `STARTTLS`, и неявный TLS на `IMAPS_PORT` (993), если задан. Сертификат тот же, что у SMTP. Вход (`LOGIN`, `AUTHENTICATE PLAIN`) — только внутри TLS, без него сервер отвечает `LOGINDISABLED`.
- **Возможности:** IMAP4rev1, LITERAL+, IDLE (новые письма приходят сами, проверка раз в 5 с), UIDPLUS, MOVE, UNSELECT, NAMESPACE, SPECIAL-USE (Отправленные, Черновики, Корзина, Спам клиент находит сам), ID.
- **UID** выдаёт база (триггер): письмо, перенесённое в папку из веба или по IMAP, получает новый, больший UID — клиент его не пропустит. Флаги `\Seen`, `\Flagged`, `\Answered`, `\Deleted`; `\Draft` — у писем в «Черновиках».
- **Письмо целиком** (`BODY[]`) — ровно в том виде, в каком оно пришло по SMTP или через `APPEND`. Для писем, написанных в веб-почте, MIME собирается один раз и сохраняется: размер и байты не меняются между запросами.
- Каждая команда выполняется на рабочем потоке, не на сетевом. Команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается.
- Папки — плоские (без вложенных), имена — modified UTF-7, кириллица работает.
## Веб-почта
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` (имя файла = имя переменной). Секреты без значений по умолчанию — без них сервер не стартует. Их можно передать переменными или файлами в `MAILSERVER_SECRETS_DIR` (имя файла = имя переменной).
@@ -40,10 +66,17 @@
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` | | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` |
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` | | `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный, один раз в логе | `admin@<первый домен>` | | `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
| `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_REGISTRATION` | открыть регистрацию | `false` |
| `MAIL_CATCH_ALL` | ящик-ловушка: вся почта сюда, наружу ничего | — | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
| `SMTP_PORT` | порт SMTP | `3025` | | `SMTP_PORT` | порт SMTP | `3025` |
| `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` |
| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный | — |
| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) |
| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` |
| `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` |
| `IMAP_AUTH_WITHOUT_TLS` | вход в IMAP без TLS — **только** в закрытой сети | `false` |
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` | | `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` | | `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` | | `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
@@ -67,17 +100,26 @@ cd frontend && npm install && npx ng serve # http://loca
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`. В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет. - Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
- Первый ящик — `admin@mail.test`, пароль — в логах сервиса `mail` при первом запуске. - Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли печатаются в лог сервиса `mail` при первом запуске (в Jambotron: проект → Сервисы → `mail` → Логи) и лежат в файле `/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`). - Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`). - Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
### Ловушка почты для dev и staging (75.4)
В `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=` (пусто).
Чтобы 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. Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
## Не сделано ## Не сделано
- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети. - [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird. - [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
- [ ] Вложения входящих писем не сохраняются (берётся только текст или HTML). - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча).
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6). - [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).
+10 -8
View File
@@ -1,15 +1,17 @@
# Build context: repository root (docker compose: context ".", dockerfile backend/Dockerfile) # 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)
# 1. Web client (Angular) — served by the backend from classpath:/static # 1. Web client (Angular) — served by the backend from classpath:/static
FROM node:22-alpine AS web FROM node:22-bookworm-slim AS web
WORKDIR /web WORKDIR /web
COPY frontend/package*.json ./ COPY frontend/package*.json frontend/.npmrc ./
RUN npm install --no-audit --no-fund RUN npm install --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
RUN npx ng build --configuration=production RUN npx ng build --configuration=production
# 2. Backend # 2. Backend
FROM eclipse-temurin:21-jdk-alpine AS build FROM eclipse-temurin:21-jdk AS build
WORKDIR /app WORKDIR /app
COPY gradlew settings.gradle ./ COPY gradlew settings.gradle ./
COPY gradle gradle COPY gradle gradle
@@ -19,14 +21,14 @@ COPY --from=web /web/dist/frontend/browser backend/src/main/resources/static
RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test
# 3. Runtime: not root; data in /app/data (volume) # 3. Runtime: not root; data in /app/data (volume)
FROM eclipse-temurin:21-jre-alpine FROM eclipse-temurin:21-jre
# own user: "mail" already exists in Alpine (uid 8, group 12) — addgroup mail fails # Ubuntu image, curl included; own user: "mail" already exists (uid 8)
RUN apk add --no-cache curl && addgroup -S -g 10001 app && adduser -S -u 10001 -G app app \ 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 && mkdir -p /app/data/attachments /app/data/dkim-keys /app/data/tls && chown -R app:app /app/data
WORKDIR /app WORKDIR /app
COPY --from=build /app/backend/build/libs/*.jar app.jar COPY --from=build /app/backend/build/libs/*.jar app.jar
USER app USER app
ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt MAIL_TLS_DIR=/app/data/tls
EXPOSE 8080 3025 EXPOSE 8080 3025
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
@@ -9,12 +9,27 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.security.SecureRandom;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Locale;
/** /**
* First mailbox on an empty database: {@code mailserver.admin.email} (default admin@&lt;first domain&gt;) with * Mailboxes the server needs on start:
* {@code mailserver.admin.password}; no password given — a random one, printed to the log once. * <ul>
* <li>the first mailbox on an empty database — {@code mailserver.admin.email} (default admin@&lt;first domain&gt;)
* 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 printed to the log once (visible in Jambotron: project → Services → mail → Logs) and appended
* to {@code mailserver.passwords-file} (mode 600, in the data volume) — change it after the first login.
*/ */
@Configuration @Configuration
public class DataInitializer { public class DataInitializer {
@@ -24,23 +39,53 @@ public class DataInitializer {
@Bean @Bean
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains, public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
@Value("${mailserver.admin.email:}") String email, @Value("${mailserver.admin.email:}") String email,
@Value("${mailserver.admin.password:}") String password) { @Value("${mailserver.admin.password:}") String password,
@Value("${mailserver.passwords-file:}") String passwordsFile) {
return args -> { return args -> {
if (userRepository.count().block() > 0) { Path file = passwordsFile.isBlank() ? null : Path.of(passwordsFile);
return; if (userRepository.count().block() == 0) {
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(Locale.ROOT);
create(userService, address, password, "Administrator", file);
} }
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(); String catchAll = domains.catchAll();
boolean generated = password.isBlank(); if (catchAll != null && !Boolean.TRUE.equals(userRepository.existsByEmail(catchAll).block())) {
String secret = generated ? randomPassword() : password; create(userService, catchAll, "", "Mail trap", file);
userService.register(address, secret, "Administrator").block();
if (generated) {
log.warn("First mailbox created: {} — password: {} (shown once, change it)", address, secret);
} else {
log.info("First mailbox created: {}", address);
} }
}; };
} }
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);
// also in the log: the owner sees it in Jambotron (project → Services → mail → Logs) without a shell
log.warn("Mailbox created: {} — password: {} (also in {}; change it)", address, secret, 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() { private static String randomPassword() {
byte[] bytes = new byte[18]; byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes); new SecureRandom().nextBytes(bytes);
@@ -0,0 +1,59 @@
package com.mailserver.config;
import com.mailserver.imap.DbImapStore;
import com.mailserver.imap.ImapServer;
import com.mailserver.imap.ImapStore;
import com.mailserver.repository.AttachmentRepository;
import com.mailserver.repository.MessageRepository;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.MailService;
import com.mailserver.service.MessageSources;
import com.mailserver.service.UserService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
/** IMAP (75.2): STARTTLS on mailserver.imap.port, implicit TLS on mailserver.imaps.port when set. */
@Configuration
public class ImapConfig {
@Bean
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname);
}
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true)
public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls,
@Value("${mailserver.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imap.port:3143}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.imap.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral,
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false,
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store);
}
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls,
@Value("${mailserver.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imaps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral,
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true,
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store);
}
}
@@ -1,580 +0,0 @@
package com.mailserver.config;
import com.mailserver.repository.FolderRepository;
import com.mailserver.repository.MessageRepository;
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.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Встроенный IMAP-сервер для почтового сервера.
* Поддерживает основные команды IMAP4rev1: LOGIN, SELECT, FETCH, SEARCH, STORE, EXPUNGE, LOGOUT.
* Запускается при старте приложения и останавливается при завершении.
*/
@Configuration
public class ImapServerConfig {
private static final Logger log = LoggerFactory.getLogger(ImapServerConfig.class);
@Value("${mailserver.imap.enabled:true}")
private boolean enabled;
@Value("${mailserver.imap.port:143}")
private int port;
@Value("${mailserver.imap.hostname:localhost}")
private String hostname;
private final UserRepository userRepository;
private final FolderRepository folderRepository;
private final MessageRepository messageRepository;
private final UserService userService;
private ServerSocket serverSocket;
private ExecutorService executorService;
public ImapServerConfig(UserRepository userRepository, FolderRepository folderRepository,
MessageRepository messageRepository, UserService userService) {
this.userRepository = userRepository;
this.folderRepository = folderRepository;
this.messageRepository = messageRepository;
this.userService = userService;
}
@PostConstruct
public void start() {
if (!enabled) {
log.info("IMAP-сервер отключён (mailserver.imap.enabled=false)");
return;
}
executorService = Executors.newCachedThreadPool();
try {
serverSocket = new ServerSocket(port);
log.info("IMAP-сервер запущен на порту {} (хост: {})", port, hostname);
// Принимаем подключения в цикле
while (!serverSocket.isClosed()) {
try {
Socket clientSocket = serverSocket.accept();
executorService.submit(() -> handleClient(clientSocket));
} catch (IOException e) {
if (!serverSocket.isClosed()) {
log.error("Ошибка при принятии подключения", e);
}
}
}
} catch (IOException e) {
if (serverSocket == null || !serverSocket.isClosed()) {
log.error("Не удалось запустить IMAP-сервер на порту {}", port, e);
}
}
}
@PreDestroy
public void stop() {
log.info("Остановка IMAP-сервера...");
try {
if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
} catch (IOException e) {
log.error("Ошибка при остановке IMAP-сервера", e);
}
if (executorService != null) {
executorService.shutdown();
try {
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
}
log.info("IMAP-сервер остановлен");
}
private void handleClient(Socket socket) {
String clientAddress = socket.getInetAddress().getHostAddress();
log.info("Новое IMAP-подключение от {}", clientAddress);
try (socket;
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
// Отправляем приветствие
out.println("* OK IMAP4rev1 Server Ready");
String username = null;
String line;
while (username == null && (line = in.readLine()) != null) {
String[] parts = parseCommand(line);
if (parts == null) continue;
String tag = parts[0];
String command = parts[1].toUpperCase();
switch (command) {
case "LOGIN" -> {
if (parts.length >= 4) {
String email = parts[2].toLowerCase();
String password = parts[3];
// Проверяем пароль через BCrypt
com.mailserver.domain.User user = userRepository.findByEmail(email).block();
if (user != null && userService.checkPassword(user, password)) {
username = email;
out.println(tag + " OK LOGIN completed");
log.info("Пользователь {} успешно авторизован через IMAP", email);
} else {
out.println(tag + " NO LOGIN failed: invalid credentials");
log.warn("Неудачная попытка IMAP-авторизации для пользователя {}", email);
}
} else {
out.println(tag + " BAD Invalid LOGIN command");
}
}
case "CAPABILITY" -> {
out.println("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE LITERAL+");
out.println(tag + " OK Capability completed");
}
case "LOGOUT" -> {
out.println("* BYE IMAP4rev1 server terminating");
out.println(tag + " OK LOGOUT completed");
return;
}
default -> {
if (username == null) {
out.println(tag + " BAD Authentication required");
} else {
handleAuthenticatedCommand(tag, command, parts, username, out, in);
}
}
}
}
} catch (IOException e) {
log.error("Ошибка при обработке IMAP-подключения от {}", clientAddress, e);
} finally {
log.info("IMAP-подключение от {} закрыто", clientAddress);
}
}
private void handleAuthenticatedCommand(String tag, String command, String[] parts,
String username, PrintWriter out, BufferedReader in) {
switch (command) {
case "SELECT" -> handleSelect(tag, parts, username, out);
case "EXAMINE" -> handleSelect(tag, parts, username, out); // EXAMINE = SELECT в read-only режиме
case "FETCH" -> handleFetch(tag, parts, username, out);
case "SEARCH" -> handleSearch(tag, parts, username, out);
case "STORE" -> handleStore(tag, parts, username, out);
case "EXPUNGE" -> handleExpunge(tag, username, out);
case "LIST" -> handleList(tag, username, out);
case "LSUB" -> handleList(tag, username, out); // LSUB = LIST для подписок
case "STATUS" -> handleStatus(tag, parts, username, out);
case "ID" -> {
out.println("* ID (\"name\" \"MailServer IMAP\" \"version\" \"1.0\")");
out.println(tag + " OK ID completed");
}
default -> {
out.println(tag + " BAD Unknown command: " + command);
}
}
}
private void handleSelect(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 3) {
out.println(tag + " BAD Invalid SELECT command");
return;
}
String folderName = parts[2].replace("\"", "");
com.mailserver.domain.User userTemp = userRepository.findByEmail(username).block();
if (userTemp == null) {
out.println(tag + " NO User not found");
return;
}
com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(
userTemp.getId(), folderName).block();
if (folder == null) {
out.println(tag + " NO [NONEXISTENT] Mailbox does not exist");
return;
}
long messageCount = messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1)).count().block();
out.println("* " + messageCount + " EXISTS");
out.println("* 0 RECENT");
out.println("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
out.println(tag + " [OK] [UNSEEN 1] " + (messageCount > 0 ? "There are unseen messages in the mailbox." : "The mailbox has no unseen messages."));
out.println(tag + " OK [READ-WRITE] SELECT completed");
}
private void handleFetch(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 4) {
out.println(tag + " BAD Invalid FETCH command");
return;
}
String messageId = parts[2];
String item = parts[3];
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
// Получаем все сообщения пользователя
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
java.util.List<com.mailserver.domain.Message> allMessages = new java.util.ArrayList<>();
for (com.mailserver.domain.Folder folder : folders) {
allMessages.addAll(messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block());
}
// Находим сообщение по UID или индексу
com.mailserver.domain.Message message = null;
if (item.contains("UID")) {
// UID FETCH
try {
long uid = Long.parseLong(messageId);
for (com.mailserver.domain.Message msg : allMessages) {
if (msg.getId().equals(uid)) {
message = msg;
break;
}
}
} catch (NumberFormatException e) {
out.println(tag + " BAD Invalid UID");
return;
}
} else {
// FETCH по индексу (1-based)
try {
int index = Integer.parseInt(messageId) - 1;
if (index >= 0 && index < allMessages.size()) {
message = allMessages.get(index);
}
} catch (NumberFormatException e) {
out.println(tag + " BAD Invalid message set");
return;
}
}
if (message == null) {
out.println(tag + " NO Message not found");
return;
}
// Формируем ответ FETCH
StringBuilder flags = new StringBuilder();
if (message.isRead()) flags.append("\\Seen ");
if (message.isFlagged()) flags.append("\\Flagged ");
if (message.isSpam()) flags.append("\\Deleted ");
if (message.getBody() != null && !message.getBody().isEmpty()) flags.append("\\Answered ");
out.println("* " + messageId + " FETCH (");
out.println(" UID " + message.getId());
out.println(" FLAGS (" + flags.toString().trim() + ")");
out.println(" INTERNALDATE \"" + formatImapDate(message.getReceivedAt()) + "\"");
out.println(" RFC822.SIZE " + (message.getMessageSize() != null ? message.getMessageSize() : 0));
out.println(" ENVELOPE (");
out.println(" \"" + escapeString(message.getReceivedAt() != null ? message.getReceivedAt().toString() : "") + "\"");
out.println(" \"" + escapeString(message.getSubject() != null ? message.getSubject() : "") + "\"");
out.println(" (\"" + escapeString(message.getSenderName() != null ? message.getSenderName() : "") + "\" 0 \"\" \"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")");
out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")");
out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")");
out.println(" (\"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")");
out.println(" NIL");
out.println(" NIL");
out.println(" NIL");
out.println(" )");
// Если запрошено тело сообщения
if (item.contains("BODY[]") || item.contains("RFC822")) {
out.println(" BODY[] {");
String body = message.getBody() != null ? message.getBody() : "";
out.println(body.length());
out.println(body);
out.println(")");
}
out.println(")");
out.println(tag + " OK FETCH completed");
}
private void handleSearch(String tag, String[] parts, String username, PrintWriter out) {
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
com.mailserver.domain.Folder inbox = folderRepository.findByUserIdAndName(user.getId(), "INBOX").block();
if (inbox == null) {
out.println(tag + " OK SEARCH completed");
return;
}
// Простой поиск по ключевому слову
String keyword = null;
for (int i = 2; i < parts.length; i++) {
if (!parts[i].equals("ALL") && !parts[i].equals("FROM") && !parts[i].equals("SUBJECT") && !parts[i].equals("BODY")) {
keyword = parts[i];
break;
}
}
java.util.List<Long> messageIds;
if (keyword != null && !keyword.isEmpty()) {
var results = messageRepository.searchInFolder(inbox.getId(), keyword,
org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block();
messageIds = results.stream()
.map(com.mailserver.domain.Message::getId)
.collect(java.util.stream.Collectors.toList());
} else {
var results = messageRepository.findByFolderId(inbox.getId(),
org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block();
messageIds = results.stream()
.map(com.mailserver.domain.Message::getId)
.collect(java.util.stream.Collectors.toList());
}
StringBuilder ids = new StringBuilder();
for (Long id : messageIds) {
ids.append(id).append(" ");
}
out.println("* SEARCH " + ids.toString().trim());
out.println(tag + " OK SEARCH completed");
}
private void handleStore(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 5) {
out.println(tag + " BAD Invalid STORE command");
return;
}
String messageId = parts[2];
String item = parts[3].toUpperCase();
String value = parts[4].toUpperCase();
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
// Получаем все сообщения пользователя
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
java.util.List<com.mailserver.domain.Message> allMessages = new java.util.ArrayList<>();
for (com.mailserver.domain.Folder folder : folders) {
allMessages.addAll(messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block());
}
com.mailserver.domain.Message message = null;
try {
int index = Integer.parseInt(messageId) - 1;
if (index >= 0 && index < allMessages.size()) {
message = allMessages.get(index);
}
} catch (NumberFormatException e) {
out.println(tag + " BAD Invalid message set");
return;
}
if (message == null) {
out.println(tag + " NO Message not found");
return;
}
boolean saved = false;
if (item.contains("+FLAGS") || item.contains("FLAGS")) {
if (value.contains("\\SEEN")) {
message.setRead(true);
saved = true;
}
if (value.contains("\\FLAGGED")) {
message.setFlagged(true);
saved = true;
}
if (value.contains("\\DELETED")) {
message.setSpam(true); // Используем spam как флаг удаления для IMAP
saved = true;
}
}
if (item.contains("-FLAGS")) {
if (value.contains("\\SEEN")) {
message.setRead(false);
saved = true;
}
if (value.contains("\\FLAGGED")) {
message.setFlagged(false);
saved = true;
}
}
if (saved) {
messageRepository.save(message).block();
}
out.println("* " + messageId + " FETCH (FLAGS (" + formatFlags(message) + "))");
out.println(tag + " OK STORE completed");
}
private void handleExpunge(String tag, String username, PrintWriter out) {
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
for (com.mailserver.domain.Folder folder : folders) {
var messages = messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block();
for (com.mailserver.domain.Message message : messages) {
if (message.isSpam()) {
messageRepository.delete(message).block();
folder.setMessageCount(Math.max(0, folder.getMessageCount() - 1));
folderRepository.save(folder).block();
}
}
}
out.println("* 0 EXPUNGE");
out.println(tag + " OK EXPUNGE completed");
}
private void handleList(String tag, String username, PrintWriter out) {
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
for (com.mailserver.domain.Folder folder : folders) {
out.println("* LIST (\\HasNoChildren) \".\" \"" + folder.getName() + "\"");
}
out.println(tag + " OK LIST completed");
}
private void handleStatus(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 4) {
out.println(tag + " BAD Invalid STATUS command");
return;
}
String folderName = parts[2].replace("\"", "");
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(user.getId(), folderName).block();
if (folder == null) {
out.println(tag + " NO [NONEXISTENT] Mailbox does not exist");
return;
}
long messageCount = messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1)).count().block();
long unreadCount = messageRepository.countUnreadByFolderId(folder.getId()).block();
out.println("* STATUS " + folderName + " (MESSAGES " + messageCount + " UNSEEN " + unreadCount + " UIDNEXT " + (messageCount + 1) + " UIDVALIDITY 1)");
out.println(tag + " OK STATUS completed");
}
private String[] parseCommand(String line) {
if (line == null || line.trim().isEmpty()) return null;
line = line.trim();
int spaceIndex = line.indexOf(' ');
if (spaceIndex == -1) return new String[]{line, ""};
String tag = line.substring(0, spaceIndex);
String rest = line.substring(spaceIndex + 1).trim();
// Разбираем команду и аргументы
String[] parts = new String[20]; // Максимум 20 аргументов
parts[0] = tag;
int index = 1;
int i = 0;
while (i < rest.length() && index < parts.length) {
if (rest.charAt(i) == '"') {
// Строка в кавычках
int end = rest.indexOf('"', i + 1);
if (end == -1) break;
parts[index++] = rest.substring(i + 1, end);
i = end + 1;
} else if (Character.isWhitespace(rest.charAt(i))) {
i++;
} else {
int end = rest.indexOf(' ', i);
if (end == -1) end = rest.length();
parts[index++] = rest.substring(i, end);
i = end;
}
}
// Обрезаем массив до нужного размера
String[] result = new String[index];
System.arraycopy(parts, 0, result, 0, index);
return result;
}
private String formatImapDate(Instant instant) {
if (instant == null) return "";
// Формат IMAP: "dd-MMM-yyyy hh:mm:ss +0000"
String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
java.time.LocalDate date = instant.atZone(java.time.ZoneOffset.UTC).toLocalDate();
java.time.LocalTime time = instant.atZone(java.time.ZoneOffset.UTC).toLocalTime();
return String.format("%02d-%s-%04d %02d:%02d:%02d +0000",
date.getDayOfMonth(), months[date.getMonthValue() - 1], date.getYear(),
time.getHour(), time.getMinute(), time.getSecond());
}
private String escapeString(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\").replace("\"", "\\\"");
}
private String formatFlags(com.mailserver.domain.Message message) {
StringBuilder flags = new StringBuilder();
if (message.isRead()) flags.append("\\Seen ");
if (message.isFlagged()) flags.append("\\Flagged ");
if (message.isSpam()) flags.append("\\Deleted ");
return flags.toString().trim();
}
}
@@ -3,12 +3,16 @@ package com.mailserver.config;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer; import com.mailserver.smtp.SmtpServer;
import com.mailserver.smtp.SmtpTls;
import io.netty.handler.ssl.SslContext;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.Locale; import java.util.Locale;
@@ -17,16 +21,46 @@ import java.util.Locale;
@EnableScheduling @EnableScheduling
public class MailServerConfig { public class MailServerConfig {
/**
* Certificate for SMTP TLS: mailserver.tls.cert-file + key-file (PEM), or a self-signed one in mailserver.tls.dir.
* mailserver.tls.enabled=false — no TLS at all.
*/
@Bean
public SmtpTlsContext smtpTlsContext(@Value("${mailserver.tls.enabled:true}") boolean enabled,
@Value("${mailserver.tls.cert-file:}") String certFile,
@Value("${mailserver.tls.key-file:}") String keyFile,
@Value("${mailserver.tls.dir:./tls}") String dir,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) throws Exception {
return new SmtpTlsContext(enabled ? SmtpTls.serverContext(certFile, keyFile, Path.of(dir), hostname) : null);
}
public record SmtpTlsContext(SslContext context) {
}
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
public SmtpServer smtpServer(MailRouter router, public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls, @Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router); return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
tls.context(), false), router);
}
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
tls.context(), true), router);
} }
/** /**
@@ -2,11 +2,13 @@ package com.mailserver.config;
import com.mailserver.security.JwtAuthenticationFilter; import com.mailserver.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain; 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.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsConfigurationSource; import org.springframework.web.cors.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@@ -42,9 +44,14 @@ public class SecurityConfig {
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) { public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
http http
.csrf(ServerHttpSecurity.CsrfSpec::disable) .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())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchange -> exchange .authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register", "/actuator/health").permitAll() .pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
.pathMatchers("/api/**", "/actuator/**").authenticated() .pathMatchers("/api/**", "/actuator/**").authenticated()
// the web client (static files, SPA routes) // the web client (static files, SPA routes)
.anyExchange().permitAll() .anyExchange().permitAll()
@@ -7,7 +7,10 @@ import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain; import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
/** Angular routes (/inbox, /message/5, …) are pages of the web client: serve index.html for them. */ /**
* Angular routes (/inbox, /message/5, /compose;to=a@b.c …) are pages of the web client: serve index.html for them.
* A last segment with a dot (before matrix parameters) is a file.
*/
@Component @Component
@Order(-100) @Order(-100)
public class SpaWebFilter implements WebFilter { public class SpaWebFilter implements WebFilter {
@@ -17,7 +20,7 @@ public class SpaWebFilter implements WebFilter {
String path = exchange.getRequest().getPath().pathWithinApplication().value(); String path = exchange.getRequest().getPath().pathWithinApplication().value();
boolean page = exchange.getRequest().getMethod().matches("GET") boolean page = exchange.getRequest().getMethod().matches("GET")
&& !path.startsWith("/api") && !path.startsWith("/actuator") && !path.startsWith("/api") && !path.startsWith("/actuator")
&& !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).contains("."); && !path.equals("/") && !path.substring(path.lastIndexOf('/') + 1).split(";")[0].contains(".");
if (page) { if (page) {
return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build()); return chain.filter(exchange.mutate().request(exchange.getRequest().mutate().path("/index.html").build()).build());
} }
@@ -1,36 +1,68 @@
package com.mailserver.controller; package com.mailserver.controller;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginRequest; import com.mailserver.dto.LoginRequest;
import com.mailserver.dto.LoginResponse; 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.service.UserService;
import com.mailserver.util.JwtUtils; import com.mailserver.util.JwtUtils;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; 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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Locale;
import java.util.Map; 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 @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class AuthenticationController { public class AuthenticationController {
private final UserService userService; /** Service addresses nobody can register (RFC 2142 and ours). */
private final JwtUtils jwtUtils; 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.userService = userService;
this.mailService = mailService;
this.jwtUtils = jwtUtils; 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") @PostMapping("/login")
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) { public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) {
return requestMono.flatMap(request -> return requestMono.flatMap(request ->
userService.findByEmail(request.getEmail()) userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT))
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) .switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials")))
.flatMap(user -> { .flatMap(user -> {
if (!user.isActive()) { if (!user.isActive()) {
@@ -39,15 +71,74 @@ public class AuthenticationController {
if (!userService.checkPassword(user, request.getPassword())) { if (!userService.checkPassword(user, request.getPassword())) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))); return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
} }
String token = jwtUtils.generateToken(user.getEmail()); return Mono.just(ResponseEntity.ok(token(user)));
LoginResponse response = new LoginResponse();
response.setToken(token);
response.setEmail(user.getEmail());
return Mono.just(ResponseEntity.ok(response));
}) })
.onErrorResume(IllegalArgumentException.class, e -> .onErrorResume(IllegalArgumentException.class, e ->
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"))) 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,31 +3,30 @@ package com.mailserver.controller;
import com.mailserver.dto.AttachmentResponse; import com.mailserver.dto.AttachmentResponse;
import com.mailserver.dto.FolderResponse; import com.mailserver.dto.FolderResponse;
import com.mailserver.dto.MessageResponse; 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.dto.SendRequest;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import jakarta.validation.Valid; 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.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart; import org.springframework.http.codec.multipart.FilePart;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import com.mailserver.config.MailDomains;
import com.mailserver.service.SubmissionService; import com.mailserver.service.SubmissionService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -51,165 +50,148 @@ public class MailController {
private final UserService userService; private final UserService userService;
private final MailService mailService; private final MailService mailService;
private final SubmissionService submissionService; private final SubmissionService submissionService;
private final MailDomains domains;
private final boolean registrationEnabled;
public MailController(UserService userService, MailService mailService, SubmissionService submissionService, public MailController(UserService userService, MailService mailService, SubmissionService submissionService) {
MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled) {
this.userService = userService; this.userService = userService;
this.mailService = mailService; this.mailService = mailService;
this.submissionService = submissionService; this.submissionService = submissionService;
this.domains = domains;
this.registrationEnabled = registrationEnabled;
} }
private Mono<String> getCurrentUserEmail(Mono<Authentication> authenticationMono) { private Mono<com.mailserver.domain.User> user(Mono<Authentication> auth) {
return authenticationMono.map(Authentication::getName); return auth.flatMap(a -> userService.findByEmail(a.getName()));
} }
// --- Users --- // --- Folders ---
@PostMapping("/register")
public Mono<ResponseEntity<?>> register(@Valid @RequestBody Mono<RegisterRequest> requestMono) {
if (!registrationEnabled) {
return Mono.just(org.springframework.http.ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Registration is closed")));
}
return requestMono.flatMap(request -> !domains.isLocal(request.getEmail())
? Mono.just(org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", "Mailboxes can only be created in: " + String.join(", ", domains.domains()))))
: userService.register(request.getEmail().trim().toLowerCase(java.util.Locale.ROOT), request.getPassword(), request.getDisplayName())
.map(user -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("message", "User registered")))
.onErrorResume(IllegalArgumentException.class, e ->
Mono.just(org.springframework.http.ResponseEntity.badRequest()
.body(Map.of("error", e.getMessage()))))
);
}
@GetMapping("/folders") @GetMapping("/folders")
public Flux<FolderResponse> getFolders(Mono<Authentication> authenticationMono) { public Flux<FolderResponse> getFolders(Mono<Authentication> auth) {
return authenticationMono.flatMapMany(auth -> return user(auth).flatMapMany(user -> mailService.folders(user.getId()));
userService.findByEmail(auth.getName()) }
.flatMapMany(user -> userService.getFolders(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 --- // --- Messages ---
/** Newest first, bodies cut to a preview. */
@GetMapping("/messages") @GetMapping("/messages")
public Flux<MessageResponse> getMessages( public Flux<MessageResponse> getMessages(Mono<Authentication> auth, @RequestParam Long folderId,
Mono<Authentication> authenticationMono, @RequestParam(defaultValue = "" + MailService.LIST_LIMIT) int limit) {
@RequestParam Long folderId, return user(auth).flatMapMany(user -> mailService.listMessages(folderId, user.getId(), limit));
@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))
);
} }
@GetMapping("/messages/search") @GetMapping("/messages/search")
public Flux<MessageResponse> searchMessages( public Flux<MessageResponse> searchMessages(
Mono<Authentication> authenticationMono, Mono<Authentication> auth,
@RequestParam Long folderId, @RequestParam Long folderId,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String subject, @RequestParam(required = false) String subject,
@RequestParam(required = false) String body, @RequestParam(required = false) String body,
@RequestParam(required = false) String sender, @RequestParam(required = false) String sender,
@RequestParam(required = false) String recipient, @RequestParam(required = false) String recipient) {
@RequestParam(defaultValue = "0") int page, return user(auth).flatMapMany(user -> keyword != null && !keyword.isBlank()
@RequestParam(defaultValue = "20") int size) { ? mailService.searchMessages(folderId, user.getId(), keyword.trim(), 0, MailService.LIST_LIMIT)
return authenticationMono.flatMapMany(auth -> : mailService.searchMessagesWithFilters(folderId, user.getId(), subject, body, sender, recipient,
userService.findByEmail(auth.getName()) 0, MailService.LIST_LIMIT));
.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);
})
);
} }
@GetMapping("/messages/{id}") @GetMapping("/messages/{id}")
public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) { public Mono<MessageResponse> getMessage(@PathVariable Long id, Mono<Authentication> auth) {
return authenticationMono.flatMap(auth -> return user(auth).flatMap(user -> mailService.getMessage(id, user.getId()));
userService.findByEmail(auth.getName())
.flatMap(user -> mailService.getMessage(id, user.getId()))
);
} }
@PostMapping("/send") /** read / flagged / folderId — only the fields that are set. */
public Mono<MessageResponse> sendMessage( @PatchMapping("/messages/{id}")
Mono<Authentication> authenticationMono, public Mono<MessageResponse> updateMessage(@PathVariable Long id, Mono<Authentication> auth,
@Valid @RequestBody Mono<SendRequest> requestMono) { @RequestBody Mono<MessageUpdateRequest> body) {
return authenticationMono.zipWith(requestMono) return user(auth).zipWith(body)
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); .flatMap(pair -> mailService.updateMessage(id, pair.getT1().getId(), pair.getT2()));
} }
@PutMapping("/messages/{id}/read") @PutMapping("/messages/{id}/read")
public Mono<ResponseEntity<?>> markAsRead(@PathVariable Long id, Mono<Authentication> authenticationMono) { public Mono<Map<String, String>> markAsRead(@PathVariable Long id, Mono<Authentication> auth) {
return authenticationMono.flatMap(auth -> return user(auth).flatMap(user -> mailService.markAsRead(id, user.getId()))
userService.findByEmail(auth.getName()) .thenReturn(Map.of("message", "Marked as read"));
.flatMap(user -> mailService.markAsRead(id, user.getId())
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Marked as read")))))
);
} }
/** Into the Trash; from the Trash — for good. */
@DeleteMapping("/messages/{id}") @DeleteMapping("/messages/{id}")
public Mono<ResponseEntity<?>> deleteMessage(@PathVariable Long id, Mono<Authentication> authenticationMono) { public Mono<Map<String, String>> deleteMessage(@PathVariable Long id, Mono<Authentication> auth) {
return authenticationMono.flatMap(auth -> return user(auth).flatMap(user -> mailService.deleteMessage(id, user.getId()))
userService.findByEmail(auth.getName()) .thenReturn(Map.of("message", "Message deleted"));
.flatMap(user -> mailService.deleteMessage(id, user.getId()) }
.then(Mono.just(org.springframework.http.ResponseEntity.ok(Map.of("message", "Message deleted")))))
); @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()));
}
// --- 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 --- // --- 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) @PostMapping(value = "/messages/{messageId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Mono<ResponseEntity<?>> uploadAttachment( public Mono<ResponseEntity<AttachmentResponse>> uploadAttachment(
@PathVariable Long messageId, @PathVariable Long messageId,
Mono<Authentication> authenticationMono, Mono<Authentication> auth,
@RequestBody Mono<Flux<FilePart>> filePartsMono) { @RequestPart("file") Mono<FilePart> fileMono) {
return authenticationMono.zipWith(filePartsMono) return user(auth).zipWith(fileMono).flatMap(pair -> {
.flatMap(pair -> { var user = pair.getT1();
String email = pair.getT1().getName(); FilePart file = pair.getT2();
return userService.findByEmail(email) String originalFilename = safeFilename(file.filename());
.flatMapMany(user -> pair.getT2()) Path filePath = Paths.get(mailService.getAttachmentsDir(), UUID.randomUUID() + "_" + originalFilename);
.next() String contentType = file.headers().getContentType() != null
.flatMap(file -> { ? file.headers().getContentType().toString() : "application/octet-stream";
String originalFilename = safeFilename(file.filename()); // the draft must be the user's before anything is written
String uniqueName = UUID.randomUUID() + "_" + originalFilename; return mailService.ownedDraft(messageId, user.getId())
Path filePath = Paths.get(mailService.getAttachmentsDir(), uniqueName); .then(Mono.fromCallable(() -> Files.createDirectories(filePath.getParent()))
try { .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()))
Files.createDirectories(filePath.getParent()); .then(file.transferTo(filePath))
} catch (IOException e) { .then(Mono.fromCallable(() -> Files.size(filePath))
return Mono.error(new RuntimeException("Failed to create directory", e)); .subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic()))
} .flatMap(size -> mailService.saveAttachmentReactive(messageId, user.getId(), originalFilename,
contentType, size, filePath.toString()))
return DataBufferUtils.join(file.content()) .map(response -> ResponseEntity.status(HttpStatus.CREATED).body(response));
.flatMap(dataBuffer -> { });
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
try {
Files.write(filePath, bytes);
} catch (IOException e) {
return Mono.error(new RuntimeException("Failed to save file", e));
}
return userService.findByEmail(email)
.flatMap(user -> mailService.saveAttachmentReactive(
messageId, user.getId(),
originalFilename,
file.headers().getContentType() != null ? file.headers().getContentType().toString() : "application/octet-stream",
bytes.length, filePath.toString()))
.map(response -> org.springframework.http.ResponseEntity.status(HttpStatus.CREATED).body(response));
});
});
});
} }
@GetMapping("/messages/{messageId}/attachments") @GetMapping("/messages/{messageId}/attachments")
@@ -246,7 +228,7 @@ public class MailController {
} }
/** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */ /** Only the last path segment, no control characters — a file name cannot leave the attachments folder. */
static String safeFilename(String name) { public static String safeFilename(String name) {
String base = name == null ? "" : name.replace('\\', '/'); String base = name == null ? "" : name.replace('\\', '/');
base = base.substring(base.lastIndexOf('/') + 1).replaceAll("[\\p{Cntrl}\"]", "").trim(); base = base.substring(base.lastIndexOf('/') + 1).replaceAll("[\\p{Cntrl}\"]", "").trim();
if (base.isEmpty() || base.equals(".") || base.equals("..")) { if (base.isEmpty() || base.equals(".") || base.equals("..")) {
@@ -24,6 +24,9 @@ public class Folder {
@Column("unread_count") @Column("unread_count")
private Integer unreadCount = 0; private Integer unreadCount = 0;
@Column("is_system")
private boolean system;
@Column("created_at") @Column("created_at")
private Instant createdAt; private Instant createdAt;
@@ -42,6 +45,9 @@ public class Folder {
public Integer getUnreadCount() { return unreadCount; } public Integer getUnreadCount() { return unreadCount; }
public void setUnreadCount(Integer unreadCount) { this.unreadCount = 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 Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
} }
@@ -24,6 +24,9 @@ public class Message {
@Column("recipient_email") @Column("recipient_email")
private String recipientEmail; private String recipientEmail;
@Column("cc_email")
private String ccEmail;
@Column("subject") @Column("subject")
private String subject; private String subject;
@@ -66,6 +69,9 @@ public class Message {
public String getRecipientEmail() { return recipientEmail; } public String getRecipientEmail() { return recipientEmail; }
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = 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 String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = 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; package com.mailserver.dto;
import java.time.Instant; /** 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) {
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; }
} }
@@ -8,6 +8,7 @@ public class MessageResponse {
private String senderEmail; private String senderEmail;
private String senderName; private String senderName;
private String recipientEmail; private String recipientEmail;
private String ccEmail;
private String subject; private String subject;
private String body; private String body;
private boolean html; private boolean html;
@@ -32,6 +33,9 @@ public class MessageResponse {
public String getRecipientEmail() { return recipientEmail; } public String getRecipientEmail() { return recipientEmail; }
public void setRecipientEmail(String recipientEmail) { this.recipientEmail = 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 String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = 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; private String email;
@NotBlank @NotBlank
@Size(min = 6, max = 100) @Size(min = 8, max = 100)
private String password; private String password;
@Size(max = 100) @Size(max = 100)
@@ -1,31 +1,46 @@
package com.mailserver.dto; package com.mailserver.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank; 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 { public class SendRequest {
@NotBlank @NotBlank
@Email @Size(max = 5000)
private String to; private String to;
@NotBlank @Size(max = 5000)
private String cc;
@Size(max = 1000)
private String subject; private String subject;
@NotBlank @Size(max = 1_000_000)
private String body; private String body;
private boolean html = false; private boolean html = false;
private Long draftId;
public String getTo() { return to; } public String getTo() { return to; }
public void setTo(String to) { this.to = 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 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 void setBody(String body) { this.body = body; }
public boolean isHtml() { return html; } public boolean isHtml() { return html; }
public void setHtml(boolean html) { this.html = html; } public void setHtml(boolean html) { this.html = html; }
public Long getDraftId() { return draftId; }
public void setDraftId(Long draftId) { this.draftId = draftId; }
} }
@@ -0,0 +1,285 @@
package com.mailserver.imap;
import com.mailserver.domain.Attachment;
import com.mailserver.domain.Message;
import com.mailserver.domain.User;
import com.mailserver.repository.AttachmentRepository;
import com.mailserver.repository.MessageRepository;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailService;
import com.mailserver.service.MessageSources;
import com.mailserver.service.UserService;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.web.server.ResponseStatusException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.UUID;
/** {@link ImapStore} over the mail server's PostgreSQL tables (the same data the web client shows). */
public class DbImapStore implements ImapStore {
private static final Logger log = LoggerFactory.getLogger(DbImapStore.class);
private final DatabaseClient db;
private final UserRepository users;
private final UserService userService;
private final MailService mailService;
private final MessageRepository messages;
private final AttachmentRepository attachments;
private final MessageSources sources;
private final String hostname;
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
String hostname) {
this.db = db;
this.users = users;
this.userService = userService;
this.mailService = mailService;
this.messages = messages;
this.attachments = attachments;
this.sources = sources;
this.hostname = hostname;
}
@Override
public Account authenticate(String username, String password) {
User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block();
if (user == null || !user.isActive() || !userService.checkPassword(user, password)) {
return null;
}
return new Account(user.getId(), user.getEmail());
}
@Override
public List<Mailbox> mailboxes(long userId) {
return db.sql("SELECT id, name, is_system, uid_validity, uid_next FROM folders WHERE user_id = :u ORDER BY id")
.bind("u", userId)
.map(row -> new Mailbox(row.get("id", Long.class), row.get("name", String.class),
Boolean.TRUE.equals(row.get("is_system", Boolean.class)),
row.get("uid_validity", Long.class), row.get("uid_next", Long.class)))
.all().collectList().block();
}
@Override
public Mailbox create(long userId, String name) {
try {
var created = mailService.createFolder(userId, name).block();
return mailboxes(userId).stream().filter(m -> m.id() == created.id()).findFirst().orElseThrow();
} catch (ResponseStatusException e) {
throw new Refused(e.getReason());
}
}
@Override
public void rename(long userId, Mailbox mailbox, String newName) {
try {
mailService.renameFolder(mailbox.id(), userId, newName).block();
} catch (ResponseStatusException e) {
throw new Refused(e.getReason());
}
}
@Override
public void delete(long userId, Mailbox mailbox) {
try {
mailService.deleteFolder(mailbox.id(), userId).block();
} catch (ResponseStatusException e) {
throw new Refused(e.getReason());
}
}
@Override
public List<Entry> entries(long folderId) {
return db.sql("SELECT id, uid, is_read, is_flagged, is_answered, is_deleted, received_at FROM messages "
+ "WHERE folder_id = :f ORDER BY uid")
.bind("f", folderId)
.map(row -> new Entry(row.get("id", Long.class), row.get("uid", Long.class),
Boolean.TRUE.equals(row.get("is_read", Boolean.class)),
Boolean.TRUE.equals(row.get("is_flagged", Boolean.class)),
Boolean.TRUE.equals(row.get("is_answered", Boolean.class)),
Boolean.TRUE.equals(row.get("is_deleted", Boolean.class)),
instant(row.get("received_at"))))
.all().collectList().block();
}
@Override
public byte[] source(long messageId) {
byte[] raw = sources.get(messageId).block();
if (raw != null) {
return raw;
}
Message message = messages.findById(messageId).block();
if (message == null) {
return new byte[0];
}
try {
List<Attachment> files = attachments.findByMessageId(messageId).collectList().block();
raw = MimeSource.build(message, files, hostname);
} catch (Exception e) {
log.warn("IMAP: cannot build message {}: {}", messageId, e.toString());
return ("Subject: " + message.getSubject() + "\r\n\r\n" + message.getBody()).getBytes();
}
sources.put(messageId, raw).block();
return raw;
}
@Override
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
db.sql("UPDATE messages SET is_read = :s, is_flagged = :f, is_answered = :a, is_deleted = :d WHERE id = :id")
.bind("s", seen).bind("f", flagged).bind("a", answered).bind("d", deleted).bind("id", messageId)
.then().block();
}
@Override
public long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered,
boolean deleted, Instant internalDate) {
try {
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
Message message = new Message();
message.setFolderId(target.id());
String senderEmail = "unknown@" + hostname;
String senderName = null;
if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) {
senderEmail = from.getAddress();
senderName = from.getPersonal();
}
message.setSenderEmail(senderEmail);
message.setSenderName(senderName);
String to = LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.TO);
message.setRecipientEmail(to == null ? "" : to);
message.setCcEmail(LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.CC));
message.setSubject(mime.getSubject() == null ? "" : mime.getSubject());
LocalDelivery.Body body = LocalDelivery.body(mime);
message.setBody(body.text());
message.setHtml(body.html());
message.setRead(seen);
message.setFlagged(flagged);
message.setSpam(target.system() && "Spam".equals(target.name()));
message.setReceivedAt(internalDate == null ? Instant.now() : internalDate);
String messageId = mime.getMessageID();
message.setMessageId(messageId == null ? UUID.randomUUID().toString() : messageId.replaceAll("^<|>$", ""));
message.setMessageSize((long) raw.length);
Message stored = messages.save(message).block();
for (LocalDelivery.FilePart file : LocalDelivery.attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
}
sources.put(stored.getId(), raw).block();
setFlags(stored.getId(), seen, flagged, answered, deleted);
return uid(stored.getId());
} catch (Refused e) {
throw e;
} catch (Exception e) {
log.warn("IMAP APPEND to {} failed: {}", target.name(), e.toString());
throw new Refused("Cannot store the message: " + e.getMessage());
}
}
@Override
public List<Long> copy(long userId, List<Entry> list, Mailbox target) {
boolean spam = target.system() && "Spam".equals(target.name());
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Long newId = db.sql("INSERT INTO messages (folder_id, sender_email, sender_name, recipient_email, cc_email, "
+ "subject, body, is_html, is_read, is_flagged, is_spam, is_answered, is_deleted, message_size, "
+ "received_at, message_id) "
+ "SELECT :t, sender_email, sender_name, recipient_email, cc_email, subject, body, is_html, "
+ "is_read, is_flagged, :spam, is_answered, is_deleted, message_size, received_at, message_id "
+ "FROM messages WHERE id = :id RETURNING id")
.bind("t", target.id()).bind("spam", spam).bind("id", entry.id())
.map(row -> row.get("id", Long.class)).one().block();
if (newId == null) {
continue;
}
for (Attachment attachment : attachments.findByMessageId(entry.id()).collectList().block()) {
copyAttachment(attachment, newId);
}
db.sql("INSERT INTO message_sources (message_id, raw) SELECT :n, raw FROM message_sources WHERE message_id = :o")
.bind("n", newId).bind("o", entry.id()).then().block();
uids.add(uid(newId));
}
return uids;
}
private void copyAttachment(Attachment attachment, long newMessageId) {
try {
Path from = Paths.get(attachment.getFilePath());
Path to = from.resolveSibling(UUID.randomUUID() + "_" + from.getFileName().toString().replaceFirst("^[0-9a-f-]{36}_", ""));
Files.copy(from, to);
Attachment copy = new Attachment();
copy.setMessageId(newMessageId);
copy.setFilename(attachment.getFilename());
copy.setContentType(attachment.getContentType());
copy.setSizeBytes(attachment.getSizeBytes());
copy.setFilePath(to.toString());
copy.setContentId(attachment.getContentId());
attachments.save(copy).block();
} catch (IOException e) {
log.warn("IMAP COPY: attachment {} not copied: {}", attachment.getFilePath(), e.toString());
}
}
@Override
public List<Long> move(long userId, List<Entry> list, Mailbox target) {
boolean spam = target.system() && "Spam".equals(target.name());
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Long uid = db.sql("UPDATE messages SET folder_id = :t, is_spam = :spam WHERE id = :id RETURNING uid")
.bind("t", target.id()).bind("spam", spam).bind("id", entry.id())
.map(row -> row.get("uid", Long.class)).one().block();
if (uid != null) {
uids.add(uid);
}
}
return uids;
}
@Override
public void expunge(List<Long> messageIds) {
for (Long id : messageIds) {
for (Attachment attachment : attachments.findByMessageId(id).collectList().block()) {
try {
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
} catch (IOException e) {
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
}
}
messages.deleteById(id).block();
}
}
private long uid(long messageId) {
return db.sql("SELECT uid FROM messages WHERE id = :id").bind("id", messageId)
.map(row -> row.get("uid", Long.class)).one().block();
}
private static Instant instant(Object value) {
if (value instanceof Instant instant) {
return instant;
}
if (value instanceof LocalDateTime local) {
return local.toInstant(ZoneOffset.UTC);
}
if (value instanceof java.time.OffsetDateTime offset) {
return offset.toInstant();
}
return Instant.EPOCH;
}
}
@@ -0,0 +1,194 @@
package com.mailserver.imap;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Reads one IMAP command: the decoder has already collected it whole, with its literals inline
* ("{n}\r\n" + n bytes, or "{n+}\r\n" + n bytes).
*/
final class ImapReader {
static final class SyntaxError extends RuntimeException {
SyntaxError(String message) {
super(message);
}
}
private final byte[] b;
private int pos;
ImapReader(byte[] command) {
this(command, true);
}
private ImapReader(byte[] command, boolean stripLineEnd) {
int end = command.length;
if (!stripLineEnd) {
this.b = command;
return;
}
if (end > 0 && command[end - 1] == '\n') {
end--;
}
if (end > 0 && command[end - 1] == '\r') {
end--;
}
this.b = java.util.Arrays.copyOf(command, end);
}
boolean atEnd() {
return pos >= b.length;
}
int peek() {
return pos < b.length ? b[pos] & 0xff : -1;
}
void sp() {
if (peek() != ' ') {
throw new SyntaxError("Expected a space");
}
while (peek() == ' ') {
pos++;
}
}
boolean optionalSp() {
if (peek() == ' ') {
sp();
return true;
}
return false;
}
void expect(char c) {
if (peek() != c) {
throw new SyntaxError("Expected '" + c + "'");
}
pos++;
}
/** Characters up to a space, parenthesis, or the end (an atom; also tags, sequence sets, flags). */
String atom() {
int start = pos;
while (pos < b.length) {
int c = b[pos] & 0xff;
if (c == ' ' || c == '(' || c == ')' || c == '{' || c == '"' || c < 0x20 || c == 0x7f) {
break;
}
pos++;
}
if (pos == start) {
throw new SyntaxError("Expected an atom");
}
return new String(b, start, pos - start, StandardCharsets.UTF_8);
}
/** Atom, quoted string or literal. */
String astring() {
int c = peek();
if (c == '"' || c == '{') {
return new String(stringBytes(), StandardCharsets.UTF_8);
}
return atom();
}
/** Quoted string or literal, raw bytes. */
byte[] stringBytes() {
int c = peek();
if (c == '"') {
pos++;
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
while (true) {
if (pos >= b.length) {
throw new SyntaxError("Unterminated quoted string");
}
byte x = b[pos++];
if (x == '"') {
return out.toByteArray();
}
if (x == '\\' && pos < b.length) {
x = b[pos++];
}
out.write(x);
}
}
if (c == '{') {
int close = indexOf('}', pos);
if (close < 0) {
throw new SyntaxError("Bad literal");
}
String size = new String(b, pos + 1, close - pos - 1, StandardCharsets.US_ASCII);
if (size.endsWith("+")) {
size = size.substring(0, size.length() - 1);
}
int length;
try {
length = Integer.parseInt(size);
} catch (NumberFormatException e) {
throw new SyntaxError("Bad literal size");
}
pos = close + 1;
if (peek() == '\r') {
pos++;
}
expect('\n');
if (pos + length > b.length) {
throw new SyntaxError("Literal shorter than announced");
}
byte[] data = java.util.Arrays.copyOfRange(b, pos, pos + length);
pos += length;
return data;
}
throw new SyntaxError("Expected a string");
}
/** "(a b c)" → [a, b, c] (atoms or strings); an atom without parentheses → [atom]. */
List<String> list() {
List<String> items = new ArrayList<>();
if (peek() != '(') {
items.add(astring());
return items;
}
pos++;
while (peek() == ' ') {
pos++;
}
while (peek() != ')') {
if (atEnd()) {
throw new SyntaxError("Unterminated list");
}
items.add(astring());
while (peek() == ' ') {
pos++;
}
}
pos++;
return items;
}
/** Everything left, as text (used for FETCH attributes and SEARCH keys parsed by their own readers). */
String rest() {
String text = new String(b, pos, b.length - pos, StandardCharsets.UTF_8);
pos = b.length;
return text;
}
/** A reader over the rest, as is (SEARCH may hold literals). */
ImapReader restReader() {
ImapReader reader = new ImapReader(java.util.Arrays.copyOfRange(b, pos, b.length), false);
pos = b.length;
return reader;
}
private int indexOf(char c, int from) {
for (int i = from; i < b.length; i++) {
if (b[i] == c) {
return i;
}
}
return -1;
}
}
@@ -0,0 +1,260 @@
package com.mailserver.imap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
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.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import reactor.netty.Connection;
import reactor.netty.DisposableServer;
import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* IMAP server (RFC 3501) on Reactor Netty.
* <ul>
* <li>{@link CommandDecoder} collects whole commands with their literals ({n} — after "+ Ready", {n+} — at once);</li>
* <li>each command runs on a worker thread (database, MIME parsing), never on the event loop; responses are written
* in order, one command at a time;</li>
* <li>TLS: STARTTLS, or implicit TLS on a separate port (993); LOGIN/AUTHENTICATE only inside TLS unless
* {@code authWithoutTls}; plain text sent together with STARTTLS is never executed;</li>
* <li>IDLE: changes of the selected mailbox are pushed every {@code idlePoll} until DONE.</li>
* </ul>
*/
public class ImapServer {
private static final Logger log = LoggerFactory.getLogger(ImapServer.class);
static final int MAX_LINE = 64 * 1024;
private static final int MAX_QUEUED = 1000;
private static final Pattern LITERAL = Pattern.compile("\\{(\\d+)(\\+?)}\r?\n$");
private static final Pattern IDLE = Pattern.compile("^(\\S+) IDLE\\s*$", Pattern.CASE_INSENSITIVE);
private static final Duration IDLE_MAX = Duration.ofMinutes(29);
/**
* @param tls certificate; null — no TLS
* @param implicitTls TLS from the first byte (port 993)
* @param maxLiteral the largest literal accepted (APPEND of a big message)
* @param idlePoll how often IDLE looks for changes
*/
public record Settings(String host, int port, String hostname, boolean authWithoutTls, SslContext tls,
boolean implicitTls, int maxLiteral, Duration idleTimeout, Duration idlePoll) {
}
/** A whole command and whether it arrived over TLS. */
record Command(byte[] bytes, boolean tls) {
}
private final Settings settings;
private final ImapStore store;
private DisposableServer server;
public ImapServer(Settings settings, ImapStore store) {
this.settings = settings;
this.store = store;
}
public void start() {
if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host())
.port(settings.port())
.doOnConnection(connection -> connection
.addHandlerFirst("imap-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("imap-commands", new CommandDecoder(settings.maxLiteral())));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp.handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"};
inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress();
}
});
ImapSession session = new ImapSession(
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
store, remoteIp[0], settings.implicitTls());
@SuppressWarnings("unchecked")
Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1];
Flux<byte[]> responses = inbound.receiveObject().cast(Command.class)
// DONE ends IDLE while the IDLE command is still running — handled before the queue
.filter(command -> {
if (session.idling() && isDone(command.bytes())) {
if (idleDone[0] != null) {
idleDone[0].tryEmitEmpty();
}
return false;
}
return true;
})
// read commands as they come (so DONE is seen during IDLE), run them one by one
.onBackpressureBuffer(MAX_QUEUED, dropped -> log.warn("IMAP {}: too many queued commands", session.remoteIp))
.concatMap(command -> {
if (session.tls && !command.tls()) {
// plain text sent together with STARTTLS: never executed, the connection is closed
log.warn("IMAP {}: plain-text command after STARTTLS, closing", session.remoteIp);
session.closing = true;
connection[0].dispose();
return Flux.empty();
}
String text = new String(command.bytes(), StandardCharsets.UTF_8).trim();
Matcher idle = IDLE.matcher(text);
if (idle.matches()) {
idleDone[0] = Sinks.empty();
return idle(session, idle.group(1), idleDone[0]);
}
return blocking(() -> session.handle(command.bytes()));
})
.onErrorResume(e -> {
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just(ImapSession.line("* BYE " + e.getMessage()));
})
.takeUntil(response -> session.closing);
return Flux.concat(Mono.just(session.greeting()), responses)
.concatMap(response -> response == ImapSession.STARTTLS_READY
? startTls(connection[0], session)
: send(outbound, response))
.then();
})
.bindNow(Duration.ofSeconds(10));
log.info("IMAP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private Flux<byte[]> idle(ImapSession session, String tag, Sinks.Empty<Void> done) {
Flux<byte[]> polls = Flux.interval(settings.idlePoll())
.onBackpressureDrop()
.concatMap(tick -> blocking(session::idlePoll))
.takeUntilOther(done.asMono())
.take(IDLE_MAX);
return Flux.concat(
blocking(() -> session.idleStart(tag)),
Flux.defer(() -> session.idling() ? polls : Flux.empty()),
blocking(session::idleEnd));
}
private static boolean isDone(byte[] bytes) {
return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE");
}
private static Flux<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
}
private static Mono<Void> send(NettyOutbound outbound, byte[] response) {
return outbound.send(Mono.just(Unpooled.wrappedBuffer(response))).then();
}
/** After the tagged OK of STARTTLS has left in plain text: TLS in front, a fresh decoder (no plain leftovers). */
private Mono<Void> startTls(Connection connection, ImapSession session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("imap-commands", "imap-commands", new CommandDecoder(settings.maxLiteral()));
pipeline.addFirst("imap-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (!future.isSuccess()) {
log.info("IMAP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
}
sink.success();
}));
}
public int port() {
return server.port();
}
public void stop() {
if (server != null) {
server.disposeNow(Duration.ofSeconds(5));
}
}
/**
* Collects one command: lines plus the literals they announce. A synchronizing literal {n} gets "+ Ready" first;
* {n+} (LITERAL+) is read at once. Marks whether the command was read behind TLS.
*/
static final class CommandDecoder extends ByteToMessageDecoder {
private final int maxLiteral;
private final ByteArrayOutputStream command = new ByteArrayOutputStream();
private int literalLeft;
private int lineBytes;
CommandDecoder(int maxLiteral) {
this.maxLiteral = maxLiteral;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
while (in.isReadable()) {
if (literalLeft > 0) {
int n = Math.min(literalLeft, in.readableBytes());
byte[] chunk = new byte[n];
in.readBytes(chunk);
command.write(chunk, 0, n);
literalLeft -= n;
continue;
}
int newline = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) '\n');
if (newline < 0) {
if (lineBytes + in.readableBytes() > MAX_LINE) {
throw new TooLongFrameException("Command line too long");
}
return;
}
int length = newline - in.readerIndex() + 1;
if (lineBytes + length > MAX_LINE) {
throw new TooLongFrameException("Command line too long");
}
byte[] line = new byte[length];
in.readBytes(line);
command.write(line, 0, line.length);
lineBytes += length;
Matcher literal = LITERAL.matcher(new String(line, StandardCharsets.ISO_8859_1));
if (literal.find()) {
long size = Long.parseLong(literal.group(1));
if (size > maxLiteral) {
ctx.writeAndFlush(Unpooled.copiedBuffer("* BYE Literal too large\r\n", StandardCharsets.US_ASCII));
throw new TooLongFrameException("Literal too large: " + size);
}
literalLeft = (int) size;
if (literal.group(2).isEmpty()) {
ctx.writeAndFlush(Unpooled.copiedBuffer("+ Ready for literal data\r\n", StandardCharsets.US_ASCII));
}
continue;
}
out.add(new Command(command.toByteArray(), ctx.pipeline().get(SslHandler.class) != null));
command.reset();
lineBytes = 0;
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
package com.mailserver.imap;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
/**
* Mailboxes and messages as IMAP sees them. Blocking: the IMAP server calls it from a worker thread,
* never from the network event loop.
*/
public interface ImapStore {
/** A refusal the client sees as "NO reason". */
final class Refused extends RuntimeException {
public Refused(String reason) {
super(reason);
}
}
record Account(long userId, String email) {
}
record Mailbox(long id, String name, boolean system, long uidValidity, long uidNext) {
boolean isInbox() {
return system && "INBOX".equalsIgnoreCase(name);
}
boolean isDrafts() {
return system && "Drafts".equals(name);
}
/** RFC 6154 attribute, or null. */
String specialUse() {
if (!system) {
return null;
}
return switch (name) {
case "Drafts" -> "\\Drafts";
case "Sent" -> "\\Sent";
case "Spam" -> "\\Junk";
case "Trash" -> "\\Trash";
default -> null;
};
}
boolean named(String other) {
return isInbox() ? "INBOX".equalsIgnoreCase(other) : name.toLowerCase(Locale.ROOT).equals(other.toLowerCase(Locale.ROOT));
}
}
record Entry(long id, long uid, boolean seen, boolean flagged, boolean answered, boolean deleted, Instant internalDate) {
Entry withFlags(boolean seen, boolean flagged, boolean answered, boolean deleted) {
return new Entry(id, uid, seen, flagged, answered, deleted, internalDate);
}
}
/** null — wrong user or password. */
Account authenticate(String username, String password);
List<Mailbox> mailboxes(long userId);
Mailbox create(long userId, String name);
void rename(long userId, Mailbox mailbox, String newName);
/** An own folder: its messages go to the Trash (as in the web client), then the folder is removed. */
void delete(long userId, Mailbox mailbox);
/** Messages of the folder in UID order. */
List<Entry> entries(long folderId);
/** The full MIME text of the message (the original one, or built once and kept). */
byte[] source(long messageId);
void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted);
/** Stores the message into the folder; returns its UID. */
long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered,
boolean deleted, Instant internalDate);
/** Copies; returns the new UIDs in the same order. */
List<Long> copy(long userId, List<Entry> messages, Mailbox target);
/** Moves; returns the new UIDs in the same order. */
List<Long> move(long userId, List<Entry> messages, Mailbox target);
/** Removes the messages for good (with attachment files). */
void expunge(List<Long> messageIds);
}
@@ -0,0 +1,255 @@
package com.mailserver.imap;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.ContentDisposition;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.internet.ParameterList;
import jakarta.mail.internet.ParseException;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.Locale;
/** Builds IMAP response bytes: strings, literals, ENVELOPE, BODYSTRUCTURE (RFC 3501 7.4.2). */
final class ImapWriter {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImapWriter text(String text) {
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
out.write(bytes, 0, bytes.length);
return this;
}
ImapWriter sp() {
out.write(' ');
return this;
}
ImapWriter crlf() {
return text("\r\n");
}
ImapWriter literal(byte[] bytes) {
text("{" + bytes.length + "}\r\n");
out.write(bytes, 0, bytes.length);
return this;
}
/** Quoted when it is plain ASCII without CR/LF, a literal otherwise; null → NIL. */
ImapWriter nstring(String value) {
if (value == null) {
return text("NIL");
}
return string(value);
}
ImapWriter string(String value) {
boolean quotable = value.length() < 1024;
for (int i = 0; i < value.length() && quotable; i++) {
char c = value.charAt(i);
if (c > 0x7e || c < 0x20) {
quotable = false;
}
}
if (!quotable) {
return literal(value.getBytes(StandardCharsets.UTF_8));
}
return text("\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"");
}
byte[] bytes() {
return out.toByteArray();
}
// ---------- ENVELOPE ----------
ImapWriter envelope(MimeTree message) {
text("(");
nstring(message.header("Date")).sp();
nstring(message.header("Subject")).sp();
String from = message.header("From");
addresses(from).sp();
addresses(firstNonNull(message.header("Sender"), from)).sp();
addresses(firstNonNull(message.header("Reply-To"), from)).sp();
addresses(message.header("To")).sp();
addresses(message.header("Cc")).sp();
addresses(message.header("Bcc")).sp();
nstring(message.header("In-Reply-To")).sp();
nstring(message.header("Message-ID"));
return text(")");
}
private ImapWriter addresses(String header) {
if (header == null || header.isBlank()) {
return text("NIL");
}
InternetAddress[] list;
try {
list = InternetAddress.parseHeader(header, false);
} catch (AddressException e) {
return text("NIL");
}
if (list.length == 0) {
return text("NIL");
}
text("(");
for (InternetAddress address : list) {
String email = address.getAddress() == null ? "" : address.getAddress();
int at = email.lastIndexOf('@');
text("(");
nstring(encodedPersonal(address.getPersonal())).sp().text("NIL").sp();
nstring(at > 0 ? email.substring(0, at) : email).sp();
nstring(at > 0 ? email.substring(at + 1) : null);
text(")");
}
return text(")");
}
private static String encodedPersonal(String personal) {
if (personal == null) {
return null;
}
try {
return MimeUtility.encodeWord(personal, "UTF-8", "B");
} catch (UnsupportedEncodingException e) {
return personal;
}
}
// ---------- BODYSTRUCTURE / BODY ----------
ImapWriter bodyStructure(MimeTree part, boolean extensible) {
text("(");
if (part.isMultipart() && !part.children.isEmpty()) {
for (MimeTree child : part.children) {
bodyStructure(child, extensible);
}
sp().string(part.type.getSubType().toUpperCase(Locale.ROOT));
if (extensible) {
sp().params(part.type.getParameterList()).sp();
disposition(part).sp().text("NIL NIL");
}
return text(")");
}
String primary = part.isMultipart() ? "TEXT" : part.type.getPrimaryType().toUpperCase(Locale.ROOT);
String sub = part.isMultipart() ? "PLAIN" : part.type.getSubType().toUpperCase(Locale.ROOT);
string(primary).sp().string(sub).sp();
params(part.type.getParameterList()).sp();
nstring(part.header("Content-ID")).sp();
nstring(part.header("Content-Description")).sp();
String encoding = part.header("Content-Transfer-Encoding");
string(encoding == null ? "7BIT" : encoding.toUpperCase(Locale.ROOT)).sp();
text(String.valueOf(part.bodySize()));
if ("TEXT".equals(primary)) {
sp().text(String.valueOf(part.bodyLines()));
} else if (part.message != null) {
sp().envelope(part.message).sp().bodyStructure(part.message, extensible).sp().text(String.valueOf(part.bodyLines()));
}
if (extensible) {
sp().nstring(part.header("Content-MD5")).sp();
disposition(part).sp();
nstring(part.header("Content-Language")).sp();
nstring(part.header("Content-Location"));
}
return text(")");
}
private ImapWriter params(ParameterList params) {
if (params == null || params.size() == 0) {
return text("NIL");
}
text("(");
boolean first = true;
Enumeration<String> names = params.getNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
if (!first) {
sp();
}
first = false;
string(name.toUpperCase(Locale.ROOT)).sp().string(String.valueOf(params.get(name)));
}
return text(")");
}
private ImapWriter disposition(MimeTree part) {
String header = part.header("Content-Disposition");
if (header == null) {
return text("NIL");
}
try {
ContentDisposition disposition = new ContentDisposition(header);
text("(").string(disposition.getDisposition().toUpperCase(Locale.ROOT)).sp();
params(disposition.getParameterList());
return text(")");
} catch (ParseException e) {
return text("NIL");
}
}
private static String firstNonNull(String a, String b) {
return a != null ? a : b;
}
// ---------- modified UTF-7 (RFC 3501 5.1.3) for mailbox names ----------
static String encodeMailbox(String name) {
StringBuilder out = new StringBuilder();
StringBuilder pending = new StringBuilder();
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (c >= 0x20 && c <= 0x7e) {
flushBase64(out, pending);
out.append(c == '&' ? "&-" : String.valueOf(c));
} else {
pending.append(c);
}
}
flushBase64(out, pending);
return out.toString();
}
private static void flushBase64(StringBuilder out, StringBuilder pending) {
if (pending.length() == 0) {
return;
}
byte[] utf16 = pending.toString().getBytes(StandardCharsets.UTF_16BE);
String base64 = java.util.Base64.getEncoder().withoutPadding().encodeToString(utf16).replace('/', ',');
out.append('&').append(base64).append('-');
pending.setLength(0);
}
static String decodeMailbox(String name) {
StringBuilder out = new StringBuilder();
int i = 0;
while (i < name.length()) {
char c = name.charAt(i);
if (c != '&') {
out.append(c);
i++;
continue;
}
int dash = name.indexOf('-', i);
if (dash < 0) {
out.append(name.substring(i));
break;
}
if (dash == i + 1) {
out.append('&');
} else {
try {
byte[] utf16 = java.util.Base64.getDecoder().decode(name.substring(i + 1, dash).replace(',', '/'));
out.append(new String(utf16, StandardCharsets.UTF_16BE));
} catch (IllegalArgumentException e) {
out.append(name, i, dash + 1);
}
}
i = dash + 1;
}
return out.toString();
}
}
@@ -0,0 +1,129 @@
package com.mailserver.imap;
import com.mailserver.domain.Attachment;
import com.mailserver.domain.Message;
import jakarta.activation.DataHandler;
import jakarta.activation.FileDataSource;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
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 java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
/**
* MIME text of a message that has no original (written in the web client): built from the stored fields and
* attachment files. The boundary and Message-ID come from the message id, so the result is the same every time.
*/
public final class MimeSource {
private MimeSource() {
}
public static byte[] build(Message message, List<Attachment> attachments, String hostname)
throws MessagingException, IOException {
Properties props = new Properties();
props.put("mail.mime.splitlongparameters", "true");
Session session = Session.getInstance(props);
String messageId = message.getMessageId() == null || message.getMessageId().isBlank()
? message.getId() + "@" + hostname
: message.getMessageId().replaceAll("^<|>$", "");
if (!messageId.contains("@")) {
messageId = messageId + "@" + hostname;
}
String finalMessageId = "<" + messageId + ">";
MimeMessage mime = new MimeMessage(session) {
@Override
protected void updateMessageID() throws MessagingException {
setHeader("Message-ID", finalMessageId);
}
};
String from = message.getSenderEmail() == null ? "unknown@" + hostname : message.getSenderEmail();
mime.setFrom(new InternetAddress(from, message.getSenderName(), "UTF-8"));
List<InternetAddress> to = addresses(message.getRecipientEmail());
if (!to.isEmpty()) {
mime.setRecipients(jakarta.mail.Message.RecipientType.TO, to.toArray(new InternetAddress[0]));
}
List<InternetAddress> cc = addresses(message.getCcEmail());
if (!cc.isEmpty()) {
mime.setRecipients(jakarta.mail.Message.RecipientType.CC, cc.toArray(new InternetAddress[0]));
}
mime.setSubject(message.getSubject() == null ? "" : message.getSubject(), "UTF-8");
if (message.getReceivedAt() != null) {
mime.setSentDate(Date.from(message.getReceivedAt()));
}
String body = message.getBody() == null ? "" : message.getBody();
String subtype = message.isHtml() ? "html" : "plain";
if (attachments.isEmpty()) {
mime.setText(body, "UTF-8", subtype);
} else {
MimeMultipart mixed = new FixedBoundaryMultipart("mixed", "=_rms_" + message.getId());
MimeBodyPart text = new MimeBodyPart();
text.setText(body, "UTF-8", subtype);
mixed.addBodyPart(text);
for (Attachment attachment : attachments) {
Path file = Path.of(attachment.getFilePath());
if (!Files.isRegularFile(file)) {
continue;
}
MimeBodyPart part = new MimeBodyPart();
FileDataSource source = new FileDataSource(file.toFile()) {
@Override
public String getContentType() {
return attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType();
}
};
part.setDataHandler(new DataHandler(source));
part.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
part.setDisposition(attachment.getContentId() != null ? MimeBodyPart.INLINE : MimeBodyPart.ATTACHMENT);
if (attachment.getContentId() != null) {
part.setContentID("<" + attachment.getContentId().replaceAll("^<|>$", "") + ">");
}
mixed.addBodyPart(part);
}
mime.setContent(mixed);
}
mime.saveChanges();
ByteArrayOutputStream out = new ByteArrayOutputStream();
mime.writeTo(out);
return out.toByteArray();
}
private static List<InternetAddress> addresses(String list) {
List<InternetAddress> result = new ArrayList<>();
if (list == null || list.isBlank()) {
return result;
}
for (String one : list.split("[,;]")) {
String address = one.trim();
if (!address.isEmpty()) {
try {
result.add(new InternetAddress(address));
} catch (MessagingException ignored) {
// a broken address is left out, the message is still served
}
}
}
return result;
}
/** MimeMultipart with a given boundary instead of a random one. */
private static final class FixedBoundaryMultipart extends MimeMultipart {
FixedBoundaryMultipart(String subtype, String boundary) {
super(subtype);
this.contentType = "multipart/" + subtype + "; boundary=\"" + boundary + "\"";
}
}
}
@@ -0,0 +1,250 @@
package com.mailserver.imap;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.InternetHeaders;
import jakarta.mail.internet.ParseException;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* A message split into MIME parts by byte offsets — what IMAP needs for BODYSTRUCTURE and BODY[section]:
* every part is a range of the original bytes, so sizes and contents match BODY[] exactly.
*/
final class MimeTree {
private static final int MAX_DEPTH = 20;
final byte[] raw;
/** Part = [start, end); headers = [start, bodyStart), the blank line included. */
final int start;
final int bodyStart;
final int end;
final InternetHeaders headers;
final ContentType type;
final List<MimeTree> children;
/** The encapsulated message of a message/rfc822 part. */
final MimeTree message;
private MimeTree(byte[] raw, int start, int end, int depth) {
this.raw = raw;
this.start = start;
this.end = end;
this.bodyStart = headerEnd(raw, start, end);
this.headers = parseHeaders(raw, start, bodyStart);
this.type = contentType(headers);
List<MimeTree> parts = new ArrayList<>();
MimeTree nested = null;
if (depth < MAX_DEPTH) {
if (isMultipart()) {
String boundary = type.getParameter("boundary");
if (boundary != null && !boundary.isEmpty()) {
split(boundary, depth, parts);
}
} else if (is("message", "rfc822") || is("message", "global")) {
nested = new MimeTree(raw, bodyStart, end, depth + 1);
}
}
this.children = Collections.unmodifiableList(parts);
this.message = nested;
}
static MimeTree parse(byte[] raw) {
return new MimeTree(raw, 0, raw.length, 0);
}
boolean isMultipart() {
return "multipart".equalsIgnoreCase(type.getPrimaryType());
}
boolean is(String primary, String sub) {
return primary.equalsIgnoreCase(type.getPrimaryType()) && sub.equalsIgnoreCase(type.getSubType());
}
byte[] all() {
return slice(start, end);
}
byte[] header() {
return slice(start, bodyStart);
}
byte[] body() {
return slice(bodyStart, end);
}
int bodySize() {
return end - bodyStart;
}
/** Lines of the body (for text parts in BODYSTRUCTURE). */
int bodyLines() {
int lines = 0;
for (int i = bodyStart; i < end; i++) {
if (raw[i] == '\n') {
lines++;
}
}
if (end > bodyStart && raw[end - 1] != '\n') {
lines++;
}
return lines;
}
/** Header value unfolded, or null. */
String header(String name) {
String[] values = headers.getHeader(name);
if (values == null || values.length == 0 || values[0] == null) {
return null;
}
return values[0].replaceAll("\r?\n(?=[ \t])", "").trim();
}
/** Only these header fields (or all but these), each with its folded lines, plus the blank line. */
byte[] headerFields(Set<String> names, boolean not) {
StringBuilder out = new StringBuilder();
String text = new String(raw, start, bodyStart - start, StandardCharsets.ISO_8859_1);
String[] lines = text.split("\r?\n", -1);
boolean keep = false;
for (String line : lines) {
if (line.isEmpty()) {
continue;
}
if (line.charAt(0) == ' ' || line.charAt(0) == '\t') {
if (keep) {
out.append(line).append("\r\n");
}
continue;
}
int colon = line.indexOf(':');
String name = colon > 0 ? line.substring(0, colon).trim().toUpperCase(Locale.ROOT) : "";
keep = names.contains(name) != not;
if (keep) {
out.append(line).append("\r\n");
}
}
out.append("\r\n");
return out.toString().getBytes(StandardCharsets.ISO_8859_1);
}
private byte[] slice(int from, int to) {
byte[] copy = new byte[Math.max(0, to - from)];
System.arraycopy(raw, from, copy, 0, copy.length);
return copy;
}
/** Body parts between "--boundary" lines; the CRLF before a delimiter belongs to the delimiter. */
private void split(String boundary, int depth, List<MimeTree> parts) {
byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
int partStart = -1;
int i = bodyStart;
while (i < end) {
int lineEnd = indexOf(raw, (byte) '\n', i, end);
int next = lineEnd < 0 ? end : lineEnd + 1;
if (startsWith(raw, i, end, delimiter)) {
int after = i + delimiter.length;
boolean close = after + 1 < end && raw[after] == '-' && raw[after + 1] == '-';
if (partStart >= 0) {
int partEnd = i;
if (partEnd > partStart && raw[partEnd - 1] == '\n') {
partEnd--;
if (partEnd > partStart && raw[partEnd - 1] == '\r') {
partEnd--;
}
}
parts.add(new MimeTree(raw, partStart, Math.max(partStart, partEnd), depth + 1));
}
if (close) {
return;
}
partStart = next;
}
i = next;
}
if (partStart >= 0 && partStart < end) { // no closing delimiter — take the rest
parts.add(new MimeTree(raw, partStart, end, depth + 1));
}
}
private static int headerEnd(byte[] raw, int start, int end) {
if (start < end && raw[start] == '\n') {
return start + 1;
}
if (start + 1 < end && raw[start] == '\r' && raw[start + 1] == '\n') {
return start + 2;
}
for (int i = start; i < end; i++) {
if (raw[i] == '\n') {
if (i + 1 < end && raw[i + 1] == '\n') {
return i + 2;
}
if (i + 2 < end && raw[i + 1] == '\r' && raw[i + 2] == '\n') {
return i + 3;
}
}
}
return end; // headers only
}
private static InternetHeaders parseHeaders(byte[] raw, int start, int end) {
try {
return new InternetHeaders(new ByteArrayInputStream(raw, start, end - start));
} catch (MessagingException e) {
return new InternetHeaders();
}
}
private static ContentType contentType(InternetHeaders headers) {
String[] values = headers.getHeader("Content-Type");
if (values != null && values.length > 0 && values[0] != null) {
try {
return new ContentType(values[0].replaceAll("\r?\n(?=[ \t])", "").trim());
} catch (ParseException ignored) {
// broken header — the RFC 2045 default
}
}
try {
return new ContentType("text/plain; charset=us-ascii");
} catch (ParseException e) {
throw new IllegalStateException(e);
}
}
private static boolean startsWith(byte[] raw, int at, int end, byte[] prefix) {
if (at + prefix.length > end) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (raw[at + i] != prefix[i]) {
return false;
}
}
return true;
}
private static int indexOf(byte[] raw, byte value, int from, int end) {
for (int i = from; i < end; i++) {
if (raw[i] == value) {
return i;
}
}
return -1;
}
/** Header names of the part, for tests and debugging. */
List<String> headerNames() {
List<String> names = new ArrayList<>();
Enumeration<jakarta.mail.Header> all = headers.getAllHeaders();
while (all.hasMoreElements()) {
names.add(all.nextElement().getName());
}
return names;
}
}
@@ -1,9 +1,22 @@
package com.mailserver.repository; package com.mailserver.repository;
import com.mailserver.domain.Attachment; 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 org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
public interface AttachmentRepository extends R2dbcRepository<Attachment, Long> { public interface AttachmentRepository extends R2dbcRepository<Attachment, Long> {
Flux<Attachment> findByMessageId(Long messageId); 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 com.mailserver.domain.Message;
import org.springframework.data.domain.Pageable; 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.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository; import org.springframework.data.r2dbc.repository.R2dbcRepository;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
@@ -9,19 +10,29 @@ import reactor.core.publisher.Mono;
public interface MessageRepository extends R2dbcRepository<Message, Long> { public interface MessageRepository extends R2dbcRepository<Message, Long> {
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC") @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC LIMIT :#{#limit}")
Flux<Message> findByFolderId(Long folderId, Pageable pageable); Flux<Message> findLatest(Long folderId, int limit);
Flux<Message> findByFolderId(Long folderId);
@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") @Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId} AND is_read = false")
Mono<Long> countUnreadByFolderId(Long folderId); Mono<Long> countUnreadByFolderId(Long folderId);
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND is_read = false ORDER BY received_at DESC") @Modifying
Flux<Message> findUnreadByFolderId(Long folderId, Pageable pageable); @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 " + @Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} AND " +
"(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + "(LOWER(subject) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
"LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + "LOWER(sender_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
"LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " + "LOWER(sender_name) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
"LOWER(recipient_email) LIKE '%' || LOWER(:#{#keyword}) || '%' OR " +
"LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " + "LOWER(body) LIKE '%' || LOWER(:#{#keyword}) || '%') " +
"ORDER BY received_at DESC") "ORDER BY received_at DESC")
Flux<Message> searchInFolder(Long folderId, String keyword, Pageable pageable); Flux<Message> searchInFolder(Long folderId, String keyword, Pageable pageable);
@@ -9,6 +9,7 @@ import jakarta.mail.Part;
import jakarta.mail.Session; import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeUtility;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -18,6 +19,8 @@ import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties; import java.util.Properties;
/** /**
@@ -35,9 +38,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
private final SpfService spfService; private final SpfService spfService;
private final DmarcService dmarcService; private final DmarcService dmarcService;
private final String hostname; private final String hostname;
private final MessageSources sources;
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService, public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) { DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
MessageSources sources) {
this.sources = sources;
this.mailService = mailService; this.mailService = mailService;
this.dkimService = dkimService; this.dkimService = dkimService;
this.spfService = spfService; this.spfService = spfService;
@@ -71,7 +77,63 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : ""); dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : "");
} }
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam).block(); com.mailserver.domain.Message stored =
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
addresses(mime, jakarta.mail.Message.RecipientType.TO),
addresses(mime, jakarta.mail.Message.RecipientType.CC)).block();
if (stored != null) {
for (FilePart file : attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
}
// IMAP serves the message exactly as it arrived
sources.put(stored.getId(), raw).block();
}
}
/** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */
public 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;
}
}
public record FilePart(String name, String contentType, byte[] bytes) {
}
/** Parts with a file name (attachments and inline images), nested multiparts walked. */
public 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. */ /** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */
@@ -87,11 +149,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
} }
} }
record Body(String text, boolean html) { public record Body(String text, boolean html) {
} }
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */ /** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
static Body body(Part part) throws MessagingException, IOException { public static Body body(Part part) throws MessagingException, IOException {
String html = find(part, "text/html"); String html = find(part, "text/html");
if (html != null) { if (html != null) {
return new Body(html, true); return new Body(html, true);
@@ -101,7 +163,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
} }
private static String find(Part part, String type) throws MessagingException, IOException { private static String find(Part part, String type) throws MessagingException, IOException {
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) { if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition()) || part.getFileName() != null) {
return null; return null;
} }
if (part.isMimeType(type)) { if (part.isMimeType(type)) {
@@ -6,6 +6,9 @@ import com.mailserver.domain.Folder;
import com.mailserver.domain.Message; import com.mailserver.domain.Message;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.AttachmentResponse; 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.MessageResponse;
import com.mailserver.repository.AttachmentRepository; import com.mailserver.repository.AttachmentRepository;
import com.mailserver.repository.ContactRepository; import com.mailserver.repository.ContactRepository;
@@ -30,6 +33,8 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.Locale;
import java.util.List; import java.util.List;
import java.util.Properties; import java.util.Properties;
import java.util.UUID; import java.util.UUID;
@@ -54,7 +59,8 @@ public class MailService {
public MailService(MessageRepository messageRepository, FolderRepository folderRepository, public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
UserRepository userRepository, AttachmentRepository attachmentRepository, UserRepository userRepository, AttachmentRepository attachmentRepository,
ContactRepository contactRepository, ContactRepository contactRepository,
SpamFilterService spamFilterService) { SpamFilterService spamFilterService, MessageSources sources) {
this.sources = sources;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.folderRepository = folderRepository; this.folderRepository = folderRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -63,16 +69,116 @@ public class MailService {
this.spamFilterService = spamFilterService; this.spamFilterService = spamFilterService;
} }
/** System folders in the order the web client shows them; the rest — own folders, by name. */
private final MessageSources sources;
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)));
}
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. */ /** Copy of a message we sent, in the sender's "Sent" folder. */
public Mono<MessageResponse> saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) { public Mono<Message> saveSent(User fromUser, String to, String cc, String subject, String body, boolean html, String messageId) {
return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent") return systemFolder(fromUser.getId(), "Sent")
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
.flatMap(sentFolder -> { .flatMap(sentFolder -> {
Message message = new Message(); Message message = new Message();
message.setFolderId(sentFolder.getId()); message.setFolderId(sentFolder.getId());
message.setSenderEmail(fromUser.getEmail()); message.setSenderEmail(fromUser.getEmail());
message.setSenderName(fromUser.getDisplayName()); message.setSenderName(fromUser.getDisplayName());
message.setRecipientEmail(to); message.setRecipientEmail(to);
message.setCcEmail(cc == null || cc.isBlank() ? null : cc);
message.setSubject(subject); message.setSubject(subject);
message.setBody(body); message.setBody(body);
message.setHtml(html); message.setHtml(html);
@@ -81,59 +187,114 @@ public class MailService {
message.setMessageId(messageId); message.setMessageId(messageId);
message.setMessageSize((long) (subject.length() + body.length())); message.setMessageSize((long) (subject.length() + body.length()));
return messageRepository.save(message); return messageRepository.save(message);
}) });
.map(this::toResponse);
} }
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail, /** Creates or updates a draft of the user (only a message in their Drafts folder can be updated). */
public Mono<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);
})
// IMAP: the draft changed — its MIME text is built anew
.flatMap(saved -> sources.remove(saved.getId()).thenReturn(saved))
.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(sources.remove(sentMessageId))
.then(messageRepository.deleteById(draftId));
}
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml) { String subject, String body, boolean isHtml) {
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false); 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) { String subject, String body, boolean isHtml, boolean externalSpamFlag) {
return userRepository.findByEmail(recipientEmail) return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, null, null);
.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();
} }
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) { /**
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt")); * Stores an incoming message: into INBOX, or into Spam when it failed the checks or the sender is blacklisted.
* {@code toHeader}/{@code ccHeader}: addresses from the message headers (shown, used by "reply all");
* without them — the mailbox itself.
*/
public Mono<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) return ownedFolder(folderId, userId)
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId(), pageable)) .flatMapMany(folder -> messageRepository.findLatest(folder.getId(), safeLimit))
.map(this::toResponse); .concatMap(this::withAttachmentCount)
.map(MailService::preview);
} }
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) { 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")); PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
return ownedFolder(folderId, userId) return ownedFolder(folderId, userId)
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable)) .flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
.map(this::toResponse); .take(LIST_LIMIT)
.concatMap(this::withAttachmentCount)
.map(MailService::preview);
} }
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId, public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
@@ -143,23 +304,74 @@ public class MailService {
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt")); PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
return ownedFolder(folderId, userId) return ownedFolder(folderId, userId)
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable)) .flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
.map(this::toResponse); .take(LIST_LIMIT)
.concatMap(this::withAttachmentCount)
.map(MailService::preview);
} }
public Mono<MessageResponse> getMessage(Long messageId, Long userId) { public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
return ownedMessage(messageId, userId).map(this::toResponse); return ownedMessage(messageId, userId).flatMap(this::withAttachmentCount);
} }
public Mono<Void> markAsRead(Long messageId, Long userId) { public Mono<Void> markAsRead(Long messageId, Long userId) {
return ownedMessage(messageId, userId) return updateMessage(messageId, userId, new MessageUpdateRequest(true, null, null)).then();
.flatMap(message -> {
message.setRead(true);
return messageRepository.save(message).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 -> {
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) { public Mono<Void> deleteMessage(Long messageId, Long userId) {
return ownedMessage(messageId, userId).flatMap(messageRepository::delete); return ownedMessage(messageId, userId)
.flatMap(message -> folderRepository.findById(message.getFolderId())
.flatMap(folder -> {
if (folder.isSystem() && "Trash".equals(folder.getName())) {
return purge(message);
}
return systemFolder(userId, "Trash").flatMap(trash -> {
message.setFolderId(trash.getId());
return messageRepository.save(message).then();
});
}));
}
private Mono<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. */ /** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */
@@ -184,12 +396,40 @@ public class MailService {
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found"))); .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;
});
}
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("&nbsp;", " ").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) { private MessageResponse toResponse(Message message) {
MessageResponse resp = new MessageResponse(); MessageResponse resp = new MessageResponse();
resp.setId(message.getId()); resp.setId(message.getId());
resp.setSenderEmail(message.getSenderEmail()); resp.setSenderEmail(message.getSenderEmail());
resp.setSenderName(message.getSenderName()); resp.setSenderName(message.getSenderName());
resp.setRecipientEmail(message.getRecipientEmail()); resp.setRecipientEmail(message.getRecipientEmail());
resp.setCcEmail(message.getCcEmail());
resp.setSubject(message.getSubject()); resp.setSubject(message.getSubject());
resp.setBody(message.getBody()); resp.setBody(message.getBody());
resp.setHtml(message.isHtml()); resp.setHtml(message.isHtml());
@@ -202,10 +442,30 @@ public class MailService {
return resp; 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, public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
String originalFilename, String contentType, String originalFilename, String contentType,
long sizeBytes, String filePath) { long sizeBytes, String filePath) {
return ownedMessage(messageId, userId) return ownedDraft(messageId, userId)
.flatMap(message -> { .flatMap(message -> {
Attachment attachment = new Attachment(); Attachment attachment = new Attachment();
attachment.setMessageId(messageId); attachment.setMessageId(messageId);
@@ -213,7 +473,7 @@ public class MailService {
attachment.setContentType(contentType); attachment.setContentType(contentType);
attachment.setSizeBytes(sizeBytes); attachment.setSizeBytes(sizeBytes);
attachment.setFilePath(filePath); attachment.setFilePath(filePath);
return attachmentRepository.save(attachment); return sources.remove(messageId).then(attachmentRepository.save(attachment));
}) })
.map(this::toAttachmentResponse); .map(this::toAttachmentResponse);
} }
@@ -236,12 +496,8 @@ public class MailService {
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) { public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
return ownedAttachment(attachmentId, userId) return ownedAttachment(attachmentId, userId)
.flatMap(attachment -> { .flatMap(attachment -> {
try { deleteFile(attachment.getFilePath());
Files.deleteIfExists(Paths.get(attachment.getFilePath())); return sources.remove(attachment.getMessageId()).then(attachmentRepository.delete(attachment));
} catch (IOException e) {
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
}
return attachmentRepository.delete(attachment);
}); });
} }
@@ -0,0 +1,38 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
/**
* The message exactly as received or appended (table message_sources). IMAP serves it as BODY[]; a message without
* one gets its MIME text built once ({@link com.mailserver.imap.MimeSource}). A changed draft loses it.
*/
@Component
public class MessageSources {
private final DatabaseClient db;
public MessageSources(DatabaseClient db) {
this.db = db;
}
public Mono<byte[]> get(Long messageId) {
return db.sql("SELECT raw FROM message_sources WHERE message_id = :id")
.bind("id", messageId)
.map(row -> row.get("raw", byte[].class))
.one();
}
public Mono<Void> put(Long messageId, byte[] raw) {
return db.sql("INSERT INTO message_sources (message_id, raw) VALUES (:id, :raw) "
+ "ON CONFLICT (message_id) DO UPDATE SET raw = EXCLUDED.raw")
.bind("id", messageId)
.bind("raw", raw)
.then();
}
public Mono<Void> remove(Long messageId) {
return db.sql("DELETE FROM message_sources WHERE message_id = :id").bind("id", messageId).then();
}
}
@@ -1,32 +1,51 @@
package com.mailserver.service; package com.mailserver.service;
import com.mailserver.domain.Attachment;
import com.mailserver.domain.User;
import com.mailserver.dto.MessageResponse; import com.mailserver.dto.MessageResponse;
import com.mailserver.dto.SendRequest; import com.mailserver.dto.SendRequest;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpBackend; import com.mailserver.smtp.SmtpBackend;
import jakarta.mail.Message; import jakarta.mail.Message;
import jakarta.mail.MessagingException;
import jakarta.mail.Session; import jakarta.mail.Session;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage; 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.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Properties; import java.util.Properties;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */ /**
* "Send" in the web client: builds the MIME message (with the draft's attachments) and routes it like an
* authenticated SMTP submission — to every To and Cc address.
*/
@Service @Service
public class SubmissionService { public class SubmissionService {
/** One message, at most this many addresses (To + Cc). */
static final int MAX_RECIPIENTS = 50;
private final UserRepository users; private final UserRepository users;
private final MailService mailService; private final MailService mailService;
private final MailRouter router; private final MailRouter router;
@@ -41,34 +60,109 @@ public class SubmissionService {
} }
public Mono<MessageResponse> send(String fromEmail, SendRequest request) { public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
String to = request.getTo().trim().toLowerCase(Locale.ROOT); 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) return users.findByEmail(fromEmail)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(user -> Mono.fromCallable(() -> { .flatMap(user -> (draftId == null ? Flux.<Attachment>empty() : mailService.draftAttachments(draftId, user.getId()))
SmtpBackend.Route route = router.route(to, user.getEmail()); .collectList()
switch (route) { .flatMap(attachments -> Mono.fromCallable(() -> submit(user, to, cc, request, attachments))
case LOCAL, OUTBOUND -> { } .subscribeOn(Schedulers.boundedElastic()))
case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to); .flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, request.getSubject(), request.getBody(), request.isHtml(), messageId))
"Sending outside is disabled on this server"); .flatMap(sent -> (draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay"); .then(mailService.getMessage(sent.getId(), user.getId()))));
}
String messageId = UUID.randomUUID() + "@" + hostname;
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to));
mime.setSubject(request.getSubject(), "UTF-8");
mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain");
mime.setSentDate(new Date());
mime.saveChanges();
mime.setHeader("Message-ID", "<" + messageId + ">");
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
mime.writeTo(bytes);
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(),
List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray());
return messageId;
}).subscribeOn(Schedulers.boundedElastic())
.flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(),
request.isHtml(), messageId)));
} }
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.Folder;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.FolderResponse;
import com.mailserver.repository.FolderRepository; import com.mailserver.repository.FolderRepository;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@@ -35,18 +34,18 @@ public class UserService {
User user = new User(); User user = new User();
user.setEmail(email); user.setEmail(email);
user.setPassword(passwordEncoder.encode(password)); 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()); user.setCreatedAt(Instant.now());
return userRepository.save(user); return userRepository.save(user);
}) })
.flatMap(user -> { .flatMap(user -> {
// Создаём стандартные папки // Создаём стандартные папки
String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"}; return Flux.fromIterable(MailService.SYSTEM_FOLDERS)
return Flux.fromArray(defaultFolders) .concatMap(name -> {
.flatMap(name -> {
Folder folder = new Folder(); Folder folder = new Folder();
folder.setUserId(user.getId()); folder.setUserId(user.getId());
folder.setName(name); folder.setName(name);
folder.setSystem(true);
folder.setCreatedAt(Instant.now()); folder.setCreatedAt(Instant.now());
return folderRepository.save(folder); return folderRepository.save(folder);
}) })
@@ -66,15 +65,17 @@ public class UserService {
return passwordEncoder.matches(rawPassword, user.getPassword()); return passwordEncoder.matches(rawPassword, user.getPassword());
} }
public Flux<FolderResponse> getFolders(Long userId) { /** New password, only with the current one. */
return folderRepository.findByUserId(userId) public Mono<Void> changePassword(User user, String currentPassword, String newPassword) {
.map(f -> { if (!checkPassword(user, currentPassword)) {
FolderResponse resp = new FolderResponse(); return Mono.error(MailService.badRequest("Current password is wrong"));
resp.setId(f.getId()); }
resp.setName(f.getName()); user.setPassword(passwordEncoder.encode(newPassword));
resp.setMessageCount(f.getMessageCount()); return userRepository.save(user).then();
resp.setUnreadCount(f.getUnreadCount()); }
return resp;
}); public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim());
return userRepository.save(user);
} }
} }
@@ -14,8 +14,9 @@ import java.util.Locale;
/** /**
* Where mail goes — one rule for SMTP and for the web client: * Where mail goes — one rule for SMTP and for the web client:
* our domain → our mailbox (or "no such user"); catch-all set → everything into that mailbox; * an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay). * (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 @Service
public class MailRouter implements SmtpBackend { public class MailRouter implements SmtpBackend {
@@ -47,12 +48,14 @@ public class MailRouter implements SmtpBackend {
@Override @Override
public Route route(String recipient, String authenticatedUser) { public Route route(String recipient, String authenticatedUser) {
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
return Route.LOCAL;
}
if (domains.catchAll() != null) { if (domains.catchAll() != null) {
return Route.LOCAL; return Route.LOCAL;
} }
if (domains.isLocal(recipient)) { if (domains.isLocal(recipient)) {
return Boolean.TRUE.equals(users.existsByEmail(recipient.toLowerCase(Locale.ROOT)).block()) return Route.UNKNOWN_USER;
? Route.LOCAL : Route.UNKNOWN_USER;
} }
if (authenticatedUser == null) { if (authenticatedUser == null) {
return Route.RELAY_DENIED; return Route.RELAY_DENIED;
@@ -67,7 +70,7 @@ public class MailRouter implements SmtpBackend {
for (Recipient recipient : envelope.recipients()) { for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) { switch (recipient.route()) {
case LOCAL -> { case LOCAL -> {
String mailbox = domains.catchAll() != null ? domains.catchAll() : recipient.address(); String mailbox = mailbox(recipient.address());
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted); localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
} }
case OUTBOUND -> { case OUTBOUND -> {
@@ -80,4 +83,16 @@ public class MailRouter implements SmtpBackend {
} }
} }
} }
/** 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());
}
} }
@@ -1,13 +1,22 @@
package com.mailserver.smtp; package com.mailserver.smtp;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.LineBasedFrameDecoder; import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.MessageToMessageDecoder;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutHandler; import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import reactor.netty.Connection;
import reactor.netty.DisposableServer; import reactor.netty.DisposableServer;
import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer; import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -32,7 +41,10 @@ import java.util.concurrent.TimeUnit;
* <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</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> * <li>database and DNS work runs on a worker thread, not on the event loop.</li>
* </ul> * </ul>
* No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network). * TLS (RFC 3207 STARTTLS, or implicit TLS on a separate port, RFC 8314): with a {@code tls} context STARTTLS is
* offered; AUTH only over TLS, or without it when {@code authWithoutTls} is on (a closed network: VM, docker network).
* After STARTTLS the session starts over (new EHLO), and lines sent in plain text together with STARTTLS are not
* executed — the connection is closed (command injection, CVE-2011-0411).
*/ */
public class SmtpServer { public class SmtpServer {
@@ -42,9 +54,32 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024; static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100; static final int MAX_RECIPIENTS = 100;
private static final int MAX_BAD_COMMANDS = 20; private static final int MAX_BAD_COMMANDS = 20;
// Compared by identity: TLS starts right after exactly this reply is written
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
/**
* @param tls certificate for STARTTLS / implicit TLS; null — no TLS
* @param implicitTls TLS from the first byte (port 465), no STARTTLS
*/
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls, public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) { Duration idleTimeout, SslContext tls, boolean implicitTls) {
public Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) {
this(host, port, hostname, maxMessageBytes, authWithoutTls, idleTimeout, null, false);
}
}
/** A line of input and whether it came over TLS. */
record Line(byte[] bytes, boolean tls) {
}
/** Marks each line: decoded behind an SslHandler (encrypted) or not. */
private static final class LineTagger extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List<Object> out) {
out.add(new Line(ByteBufUtil.getBytes(frame), ctx.pipeline().get(SslHandler.class) != null));
}
} }
private final Settings settings; private final Settings settings;
@@ -57,32 +92,70 @@ public class SmtpServer {
} }
public void start() { public void start() {
server = TcpServer.create() if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host()) .host(settings.host())
.port(settings.port()) .port(settings.port())
.doOnConnection(connection -> connection .doOnConnection(connection -> connection
.addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS)) .addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))) .addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))
.addHandlerLast("smtp-tag", new LineTagger()));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp
.handle((inbound, outbound) -> { .handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"}; String[] remoteIp = {"unknown"};
inbound.withConnection(c -> { inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) { if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress(); remoteIp[0] = address.getAddress().getHostAddress();
} }
}); });
Session session = new Session(remoteIp[0]); Session session = new Session(remoteIp[0], settings.implicitTls());
Flux<String> replies = inbound.receive().asByteArray() Flux<String> replies = inbound.receiveObject().cast(Line.class)
.concatMap(session::onLine) .concatMap(session::onLine)
.onErrorResume(e -> { .onErrorResume(e -> {
log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString()); log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n"); return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n");
}) })
.takeUntil(reply -> session.closing); .takeUntil(reply -> session.closing);
return outbound.sendString(Flux.concat( // One reply at a time, each written before the next line is handled: after "220 Ready to start TLS"
Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII); // the TLS handler goes in only when the reply has left in plain text
return Flux.concat(Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies)
.concatMap(reply -> send(outbound, reply)
.then(Mono.defer(() -> reply == STARTTLS_READY ? startTls(connection[0], session) : Mono.<Void>empty())))
.then();
}) })
.bindNow(Duration.ofSeconds(10)); .bindNow(Duration.ofSeconds(10));
log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname()); log.info("SMTP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private static Mono<Void> send(NettyOutbound outbound, String reply) {
return outbound.sendString(Mono.just(reply), StandardCharsets.US_ASCII).then();
}
/** TLS handler in front, fresh line decoder (no plain-text leftovers mixed into encrypted input). */
private Mono<Void> startTls(Connection connection, Session session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("smtp-lines", "smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false));
pipeline.addFirst("smtp-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (future.isSuccess()) {
log.debug("SMTP {}: TLS {}", session.remoteIp, ssl.engine().getSession().getProtocol());
sink.success();
} else {
log.info("SMTP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
sink.success();
}
}));
} }
public int port() { public int port() {
@@ -99,6 +172,7 @@ public class SmtpServer {
private final class Session { private final class Session {
final String remoteIp; final String remoteIp;
boolean tls;
State state = State.COMMAND; State state = State.COMMAND;
String helo; String helo;
boolean extended; boolean extended;
@@ -111,12 +185,17 @@ public class SmtpServer {
int badCommands; int badCommands;
volatile boolean closing; volatile boolean closing;
Session(String remoteIp) { Session(String remoteIp, boolean tls) {
this.remoteIp = remoteIp; this.remoteIp = remoteIp;
this.tls = tls;
} }
Mono<String> onLine(byte[] raw) { Mono<String> onLine(Line input) {
byte[] line = stripCr(raw); if (tls && !input.tls()) {
// Sent in plain text after STARTTLS, before the handshake: never execute it
return Mono.error(new IllegalStateException("plain-text input after STARTTLS"));
}
byte[] line = stripCr(input.bytes());
return switch (state) { return switch (state) {
case DATA -> onDataLine(line); case DATA -> onDataLine(line);
case AUTH_PLAIN -> authPlain(ascii(line)); case AUTH_PLAIN -> authPlain(ascii(line));
@@ -142,7 +221,7 @@ public class SmtpServer {
} }
case "NOOP" -> 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 "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message");
case "STARTTLS" -> reply("454 4.7.0 TLS not available"); case "STARTTLS" -> startTls(arg);
case "QUIT" -> { case "QUIT" -> {
closing = true; closing = true;
yield reply("221 2.0.0 " + settings.hostname() + " Bye"); yield reply("221 2.0.0 " + settings.hostname() + " Bye");
@@ -167,15 +246,41 @@ public class SmtpServer {
sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n"); sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n");
sb.append("250-8BITMIME\r\n"); sb.append("250-8BITMIME\r\n");
sb.append("250-PIPELINING\r\n"); sb.append("250-PIPELINING\r\n");
if (settings.authWithoutTls()) { if (settings.tls() != null && !tls) {
sb.append("250-STARTTLS\r\n");
}
if (authAllowed()) {
sb.append("250-AUTH PLAIN LOGIN\r\n"); sb.append("250-AUTH PLAIN LOGIN\r\n");
} }
sb.append("250 ENHANCEDSTATUSCODES\r\n"); sb.append("250 ENHANCEDSTATUSCODES\r\n");
return Mono.just(sb.toString()); return Mono.just(sb.toString());
} }
private boolean authAllowed() {
return tls || settings.authWithoutTls();
}
private Mono<String> startTls(String arg) {
if (settings.tls() == null) {
return reply("454 4.7.0 TLS not available");
}
if (tls) {
return bad("503 5.5.1 TLS already active");
}
if (!arg.isEmpty()) {
return reply("501 5.5.4 Syntax: STARTTLS");
}
// RFC 3207 4.2: everything known about the client is forgotten, a new EHLO is expected
tls = true;
helo = null;
extended = false;
authenticatedUser = null;
resetTransaction();
return Mono.just(STARTTLS_READY);
}
private Mono<String> auth(String arg) { private Mono<String> auth(String arg) {
if (!settings.authWithoutTls()) { if (!authAllowed()) {
return reply("538 5.7.11 Encryption required for requested authentication mechanism"); return reply("538 5.7.11 Encryption required for requested authentication mechanism");
} }
if (helo == null || !extended) { if (helo == null || !extended) {
@@ -369,7 +474,7 @@ public class SmtpServer {
private byte[] withReceivedHeader(byte[] message) { private byte[] withReceivedHeader(byte[] message) {
String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n" String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n"
+ "\tby " + settings.hostname() + " with " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\r\n" + "\tby " + settings.hostname() + " with " + (tls ? "ESMTPS" : "ESMTP") + (authenticatedUser != null ? "A" : "") + ";\r\n"
+ "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n"; + "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n";
byte[] header = received.getBytes(StandardCharsets.US_ASCII); byte[] header = received.getBytes(StandardCharsets.US_ASCII);
byte[] all = new byte[header.length + message.length]; byte[] all = new byte[header.length + message.length];
@@ -0,0 +1,110 @@
package com.mailserver.smtp;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
/**
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
* <ul>
* <li>{@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;</li>
* <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused —
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not
* check certificates, RFC 7435 opportunistic TLS).</li>
* </ul>
*/
public final class SmtpTls {
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
static final String CERT_FILE = "smtp-cert.pem";
static final String KEY_FILE = "smtp-key.pem";
private SmtpTls() {
}
public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception {
boolean givenCert = certFile != null && !certFile.isBlank();
boolean givenKey = keyFile != null && !keyFile.isBlank();
if (givenCert != givenKey) {
throw new IllegalStateException("TLS: set both the certificate and the key file, or neither");
}
Path cert;
Path key;
if (givenCert) {
cert = Path.of(certFile);
key = Path.of(keyFile);
log.info("SMTP TLS: certificate {}", cert);
} else {
cert = dir.resolve(CERT_FILE);
key = dir.resolve(KEY_FILE);
if (!Files.exists(cert) || !Files.exists(key)) {
selfSigned(hostname, cert, key);
log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir);
} else {
log.info("SMTP TLS: self-signed certificate {}", cert);
}
}
return SslContextBuilder.forServer(cert.toFile(), key.toFile())
.protocols("TLSv1.3", "TLSv1.2")
.build();
}
static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, new SecureRandom());
KeyPair pair = generator.generateKeyPair();
X500Name name = new X500Name("CN=" + hostname);
Instant now = Instant.now();
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))),
Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic());
builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.dNSName, hostname)));
X509Certificate certificate = new JcaX509CertificateConverter()
.getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
Files.createDirectories(keyFile.toAbsolutePath().getParent());
write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true);
write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false);
}
private static String pem(String type, byte[] der) {
return "-----BEGIN " + type + "-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der)
+ "\n-----END " + type + "-----\n";
}
private static void write(Path file, String text, boolean secret) throws IOException {
Files.writeString(file, text, StandardCharsets.US_ASCII);
if (secret) {
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// not a POSIX file system
}
}
}
}
@@ -1,5 +1,8 @@
spring.application.name=mail-server spring.application.name=mail-server
server.port=8080 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
# Secrets: environment variables, or files in a config tree (file name = variable name, e.g. /run/mail-secrets/DB_PASSWORD). # 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). # No defaults — the server does not start without them (dev profile: application-dev.properties).
@@ -18,12 +21,14 @@ spring.flyway.baseline-on-migrate=true
jwt.secret=${JWT_SECRET} jwt.secret=${JWT_SECRET}
# Mailboxes of these domains live here; catch-all: every message to any address goes into this one mailbox (mail trap) # 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.domains=${MAIL_DOMAINS:localhost}
mailserver.catch-all=${MAIL_CATCH_ALL:} mailserver.catch-all=${MAIL_CATCH_ALL:}
mailserver.registration.enabled=${MAIL_REGISTRATION:false} mailserver.registration.enabled=${MAIL_REGISTRATION:false}
mailserver.admin.email=${MAIL_ADMIN_EMAIL:} mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:} 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:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments # Attachments
@@ -36,6 +41,13 @@ mailserver.smtp.port=${SMTP_PORT:3025}
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost} mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false} mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
mailserver.smtp.max-message-bytes=26214400 mailserver.smtp.max-message-bytes=26214400
# TLS (75.1): STARTTLS on the SMTP port; implicit TLS on SMTPS_PORT (465) if set. Certificate: MAIL_TLS_CERT + MAIL_TLS_KEY
# (PEM, PKCS#8 key), otherwise a self-signed one created once in MAIL_TLS_DIR. MAIL_TLS=false — no TLS
mailserver.tls.enabled=${MAIL_TLS:true}
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
mailserver.tls.key-file=${MAIL_TLS_KEY:}
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
mailserver.smtps.port=${SMTPS_PORT:0}
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
mailserver.outbound.mode=${OUTBOUND_MODE:off} mailserver.outbound.mode=${OUTBOUND_MODE:off}
@@ -45,10 +57,12 @@ mailserver.outbound.username=${OUTBOUND_USERNAME:}
mailserver.outbound.password=${OUTBOUND_PASSWORD:} mailserver.outbound.password=${OUTBOUND_PASSWORD:}
mailserver.outbound.tls=${OUTBOUND_TLS:starttls} mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2) # IMAP (75.2): STARTTLS on IMAP_PORT, implicit TLS on IMAPS_PORT (993) if set; same certificate as SMTP.
mailserver.imap.enabled=false # LOGIN only inside TLS unless IMAP_AUTH_WITHOUT_TLS=true (a closed network only)
mailserver.imap.port=1143 mailserver.imap.enabled=${IMAP_ENABLED:true}
mailserver.imap.hostname=localhost mailserver.imap.port=${IMAP_PORT:3143}
mailserver.imaps.port=${IMAPS_PORT:0}
mailserver.imap.auth-without-tls=${IMAP_AUTH_WITHOUT_TLS:false}
# DKIM — signing of our mail; DNS record printed to the log at start # DKIM — signing of our mail; DNS record printed to the log at start
mailserver.dkim.enabled=${DKIM_ENABLED:true} mailserver.dkim.enabled=${DKIM_ENABLED:true}
@@ -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,43 @@
-- IMAP (75.2): UIDs per folder, IMAP flags, the original message text
-- UIDVALIDITY: never changes for a folder; UIDNEXT: the next UID to give out
ALTER TABLE folders ADD COLUMN uid_validity BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM now())::BIGINT);
ALTER TABLE folders ADD COLUMN uid_next BIGINT NOT NULL DEFAULT 1;
-- UID inside the folder; strictly increasing — a message moved into a folder gets a new, larger UID
ALTER TABLE messages ADD COLUMN uid BIGINT;
ALTER TABLE messages ADD COLUMN is_answered BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE messages ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE messages m SET uid = numbered.n
FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY folder_id ORDER BY received_at, id) AS n FROM messages) numbered
WHERE m.id = numbered.id;
UPDATE folders f SET uid_next = COALESCE((SELECT MAX(uid) FROM messages WHERE folder_id = f.id), 0) + 1;
ALTER TABLE messages ALTER COLUMN uid SET NOT NULL;
CREATE UNIQUE INDEX idx_messages_folder_uid ON messages(folder_id, uid);
-- The UID is given out by the database, so every path that stores or moves a message (SMTP, web client, IMAP)
-- gets it right without knowing about IMAP
CREATE FUNCTION assign_message_uid() RETURNS trigger AS $$
BEGIN
UPDATE folders SET uid_next = uid_next + 1 WHERE id = NEW.folder_id RETURNING uid_next - 1 INTO NEW.uid;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER messages_uid_on_insert BEFORE INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION assign_message_uid();
-- The web client saves the whole row (folder_id included) — only a real move gets a new UID
CREATE TRIGGER messages_uid_on_move BEFORE UPDATE OF folder_id ON messages
FOR EACH ROW WHEN (NEW.folder_id IS DISTINCT FROM OLD.folder_id) EXECUTE FUNCTION assign_message_uid();
-- The message exactly as received (SMTP) or appended (IMAP APPEND); for messages without it the server builds
-- the MIME text once from the stored fields and keeps it here, so sizes and bytes never change between fetches.
-- Removed when a draft changes.
CREATE TABLE message_sources (
message_id BIGINT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
raw BYTEA NOT NULL
);
@@ -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,345 @@
package com.mailserver.imap;
import com.mailserver.smtp.SmtpTls;
import jakarta.mail.BodyPart;
import jakarta.mail.Flags;
import jakarta.mail.Folder;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.Store;
import jakarta.mail.UIDFolder;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.search.SubjectTerm;
import org.eclipse.angus.mail.imap.AppendUID;
import org.eclipse.angus.mail.imap.IMAPFolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
class ImapServerTest {
static final String PLAIN = """
From: Bob <bob@elsewhere.test>
To: alice@example.test
Subject: Hello Alice
Date: Thu, 05 Nov 2026 10:00:00 +0000
Message-ID: <hello@elsewhere.test>
Content-Type: text/plain; charset=utf-8
Hi there, line one.
Line two.
""";
static final String WITH_FILE = """
From: =?UTF-8?B?0JDQvdC90LA=?= <anna@elsewhere.test>
To: alice@example.test
Subject: =?UTF-8?B?0J7RgtGH0ZHRgg==?=
Date: Thu, 05 Nov 2026 11:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="b1"
preamble
--b1
Content-Type: text/plain; charset=utf-8
See the report.
--b1
Content-Type: application/pdf; name="report.pdf"
Content-Disposition: attachment; filename="report.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQgdGVzdA==
--b1--
""";
@TempDir
Path tlsDir;
private MemoryImapStore store;
private ImapServer server;
@BeforeEach
void start() throws Exception {
store = new MemoryImapStore();
store.deliver("INBOX", PLAIN);
store.deliver("INBOX", WITH_FILE);
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
Duration.ofMinutes(5), Duration.ofMillis(200)), store);
server.start();
}
@AfterEach
void stop() {
server.stop();
}
private Store connect() throws Exception {
Properties props = new Properties();
props.put("mail.imap.host", "127.0.0.1");
props.put("mail.imap.port", String.valueOf(server.port()));
props.put("mail.imap.starttls.enable", "true");
props.put("mail.imap.starttls.required", "true");
props.put("mail.imap.ssl.trust", "*");
props.put("mail.imap.ssl.checkserveridentity", "false");
Store imap = Session.getInstance(props).getStore("imap");
imap.connect("alice@example.test", "secret");
return imap;
}
@Test
void realClientReadsFoldersMessagesAndAttachments() throws Exception {
try (Store imap = connect()) {
List<String> names = Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName).toList();
assertThat(names).contains("INBOX", "Drafts", "Sent", "Spam", "Trash");
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
assertThat(inbox.getMessageCount()).isEqualTo(2);
assertThat(inbox.getUnreadMessageCount()).isEqualTo(2);
Message first = inbox.getMessage(1);
assertThat(first.getSubject()).isEqualTo("Hello Alice");
assertThat(((InternetAddress) first.getFrom()[0]).getPersonal()).isEqualTo("Bob");
assertThat(first.getContent().toString()).contains("Line two.");
assertThat(first.isSet(Flags.Flag.SEEN)).isTrue(); // BODY[] without PEEK
Message second = inbox.getMessage(2);
assertThat(second.getSubject()).isEqualTo("Отчёт");
assertThat(((InternetAddress) second.getFrom()[0]).getPersonal()).isEqualTo("Анна");
Multipart parts = (Multipart) second.getContent(); // BODYSTRUCTURE, then BODY[1] and BODY[2]
assertThat(parts.getCount()).isEqualTo(2);
assertThat(parts.getBodyPart(0).getContent().toString()).contains("See the report.");
BodyPart file = parts.getBodyPart(1);
assertThat(file.getFileName()).isEqualTo("report.pdf");
assertThat(new String(file.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo("%PDF-1.4 test");
assertThat(inbox.search(new SubjectTerm("hello"))).hasSize(1);
assertThat(inbox.getUID(first)).isEqualTo(1);
assertThat(inbox.getMessageByUID(2).getSubject()).isEqualTo("Отчёт");
}
}
@Test
void appendCopyMoveAndExpunge() throws Exception {
try (Store imap = connect()) {
IMAPFolder sent = (IMAPFolder) imap.getFolder("Sent");
MimeMessage outgoing = new MimeMessage(Session.getInstance(new Properties()));
outgoing.setFrom(new InternetAddress("alice@example.test"));
outgoing.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@elsewhere.test"));
outgoing.setSubject("Sent from Thunderbird");
outgoing.setText("body");
AppendUID[] appended = sent.appendUIDMessages(new Message[]{outgoing});
assertThat(appended[0].uid).isEqualTo(1);
assertThat(appended[0].uidvalidity).isEqualTo(store.box("Sent").uidValidity());
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
inbox.copyMessages(new Message[]{inbox.getMessage(1)}, imap.getFolder("Trash"));
inbox.moveMessages(new Message[]{inbox.getMessage(2)}, imap.getFolder("Spam"));
inbox.close(false); // JavaMail counts EXPUNGEd messages until the folder is opened again
inbox.open(Folder.READ_WRITE);
assertThat(inbox.getMessageCount()).isEqualTo(1);
inbox.getMessage(1).setFlag(Flags.Flag.DELETED, true);
inbox.expunge();
assertThat(inbox.getMessageCount()).isZero();
}
assertThat(store.entries(store.box("Trash").id())).hasSize(1);
assertThat(store.entries(store.box("Spam").id())).hasSize(1);
assertThat(store.entries(store.box("Sent").id())).hasSize(1);
}
@Test
void createRenameAndDeleteFolders() throws Exception {
try (Store imap = connect()) {
Folder projects = imap.getFolder("Проекты");
assertThat(projects.create(Folder.HOLDS_MESSAGES)).isTrue();
assertThat(Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName)).contains("Проекты");
assertThat(projects.renameTo(imap.getFolder("Archive"))).isTrue();
assertThat(imap.getFolder("Archive").delete(false)).isTrue();
assertThat(imap.getFolder("Archive").exists()).isFalse();
}
}
@Test
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
try (Client client = new Client(server.port())) {
String greeting = client.read();
assertThat(greeting).contains("STARTTLS").contains("LOGINDISABLED").doesNotContain("AUTH=PLAIN");
assertThat(client.send("a LOGIN alice@example.test secret")).startsWith("a NO [PRIVACYREQUIRED]");
}
}
@Test
void literalsAndLiteralPlus() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
client.write("a LOGIN {18}\r\n");
assertThat(client.read()).startsWith("+ ");
client.write("alice@example.test {6+}\r\nsecret\r\n");
assertThat(client.read()).startsWith("a OK");
String message = "Subject: via literal+\r\n\r\nhi\r\n";
client.write("b APPEND Drafts (\\Seen) {" + message.length() + "+}\r\n" + message + "\r\n");
assertThat(client.read()).startsWith("b OK [APPENDUID");
}
assertThat(store.entries(store.box("Drafts").id())).hasSize(1);
}
@Test
void idleReportsNewMailUntilDone() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
client.send("a LOGIN alice@example.test secret");
client.untilTagged("b", "b SELECT INBOX");
client.write("c IDLE\r\n");
assertThat(client.read()).startsWith("+ idling");
store.deliver("INBOX", PLAIN);
assertThat(client.read()).isEqualTo("* 3 EXISTS");
client.write("DONE\r\n");
assertThat(client.read()).startsWith("c OK");
assertThat(client.send("d NOOP")).startsWith("d OK");
}
}
// CVE-2011-0411 class: commands sent in plain text right after STARTTLS must not run inside TLS
@Test
void plainTextAfterStartTlsIsNotExecuted() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.write("s STARTTLS\r\nx LOGIN alice@example.test secret\r\n");
assertThat(client.read()).startsWith("s OK");
String reply;
try {
client.startTls();
reply = client.read();
} catch (java.io.IOException closed) {
reply = null; // the server closed the connection — the expected outcome
}
assertThat(reply).isNull();
}
}
@Test
void sectionsOfANestedMessage() {
String raw = ("Subject: outer\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n"
+ "--x\r\nContent-Type: text/plain\r\n\r\nouter text\r\n"
+ "--x\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\nContent-Type: text/plain\r\n\r\ninner text\r\n"
+ "--x--\r\n");
MimeTree tree = MimeTree.parse(raw.getBytes(StandardCharsets.US_ASCII));
assertThat(text(ImapSession.section(tree, "1"))).isEqualTo("outer text");
assertThat(text(ImapSession.section(tree, "2.HEADER"))).isEqualTo("Subject: inner\r\nContent-Type: text/plain\r\n\r\n");
assertThat(text(ImapSession.section(tree, "2.TEXT"))).isEqualTo("inner text");
assertThat(text(ImapSession.section(tree, "2.1"))).isEqualTo("inner text");
assertThat(text(ImapSession.section(tree, "1.MIME"))).isEqualTo("Content-Type: text/plain\r\n\r\n");
assertThat(text(ImapSession.section(tree, "HEADER.FIELDS (SUBJECT)"))).isEqualTo("Subject: outer\r\n\r\n");
assertThat(text(ImapSession.section(tree, ""))).isEqualTo(raw);
}
@Test
void mailboxNamesUseModifiedUtf7() {
// the example of RFC 3501 5.1.3
assertThat(ImapWriter.encodeMailbox("~peter/mail/台北/日本語")).isEqualTo("~peter/mail/&U,BTFw-/&ZeVnLIqe-");
assertThat(ImapWriter.decodeMailbox("~peter/mail/&U,BTFw-/&ZeVnLIqe-")).isEqualTo("~peter/mail/台北/日本語");
assertThat(ImapWriter.decodeMailbox(ImapWriter.encodeMailbox("Проекты & план"))).isEqualTo("Проекты & план");
}
private static String text(byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
/** Line-oriented IMAP test client. */
static final class Client implements AutoCloseable {
private Socket socket;
private BufferedReader in;
private 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();
}
String read() throws Exception {
return in.readLine();
}
String send(String line) throws Exception {
write(line + "\r\n");
return read();
}
/** Sends a command, returns all lines up to its tagged response. */
List<String> untilTagged(String tag, String line) throws Exception {
write(line + "\r\n");
List<String> lines = new ArrayList<>();
String next;
do {
next = read();
lines.add(next);
} while (next != null && !next.startsWith(tag + " "));
return lines;
}
void tls(String tag) throws Exception {
assertThat(send(tag + " STARTTLS")).startsWith(tag + " OK");
startTls();
}
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
@Override
public void close() throws Exception {
socket.close();
}
}
}
@@ -0,0 +1,172 @@
package com.mailserver.imap;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
/** In-memory {@link ImapStore}: one user alice@example.test / secret with the system folders. */
class MemoryImapStore implements ImapStore {
static final class Stored {
final long id;
volatile long folderId;
volatile long uid;
volatile boolean seen;
volatile boolean flagged;
volatile boolean answered;
volatile boolean deleted;
final Instant date;
final byte[] raw;
Stored(long id, long folderId, long uid, byte[] raw, Instant date) {
this.id = id;
this.folderId = folderId;
this.uid = uid;
this.raw = raw;
this.date = date;
}
Entry entry() {
return new Entry(id, uid, seen, flagged, answered, deleted, date);
}
}
private final AtomicLong ids = new AtomicLong(100);
final List<Mailbox> boxes = new CopyOnWriteArrayList<>();
final Map<Long, Long> uidNext = new ConcurrentHashMap<>();
final List<Stored> messages = new CopyOnWriteArrayList<>();
MemoryImapStore() {
for (String name : List.of("INBOX", "Drafts", "Sent", "Spam", "Trash")) {
addBox(name, true);
}
}
private Mailbox addBox(String name, boolean system) {
long id = ids.incrementAndGet();
uidNext.put(id, 1L);
Mailbox box = new Mailbox(id, name, system, 1700000000L + id, 1);
boxes.add(box);
return box;
}
Mailbox box(String name) {
return boxes.stream().filter(b -> b.name().equals(name)).findFirst().orElseThrow();
}
/** Delivers a message into a folder, as SMTP would. */
synchronized Stored deliver(String folder, String raw) {
Mailbox box = box(folder);
Stored stored = new Stored(ids.incrementAndGet(), box.id(), nextUid(box.id()),
raw.replace("\n", "\r\n").replace("\r\r\n", "\r\n").getBytes(), Instant.parse("2026-11-05T10:00:00Z"));
messages.add(stored);
return stored;
}
private synchronized long nextUid(long folderId) {
long uid = uidNext.get(folderId);
uidNext.put(folderId, uid + 1);
return uid;
}
@Override
public Account authenticate(String username, String password) {
return "alice@example.test".equalsIgnoreCase(username) && "secret".equals(password)
? new Account(1, "alice@example.test") : null;
}
@Override
public List<Mailbox> mailboxes(long userId) {
return boxes.stream().map(b -> new Mailbox(b.id(), b.name(), b.system(), b.uidValidity(), uidNext.get(b.id()))).toList();
}
@Override
public synchronized Mailbox create(long userId, String name) {
return addBox(name, false);
}
@Override
public synchronized void rename(long userId, Mailbox mailbox, String newName) {
boxes.removeIf(b -> b.id() == mailbox.id());
boxes.add(new Mailbox(mailbox.id(), newName, false, mailbox.uidValidity(), mailbox.uidNext()));
}
@Override
public synchronized void delete(long userId, Mailbox mailbox) {
Mailbox trash = box("Trash");
move(userId, entries(mailbox.id()), trash);
boxes.removeIf(b -> b.id() == mailbox.id());
}
@Override
public List<Entry> entries(long folderId) {
return messages.stream().filter(m -> m.folderId == folderId)
.sorted((a, b) -> Long.compare(a.uid, b.uid)).map(Stored::entry).toList();
}
@Override
public byte[] source(long messageId) {
return find(messageId).raw;
}
private Stored find(long id) {
return messages.stream().filter(m -> m.id == id).findFirst().orElseThrow();
}
@Override
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
Stored m = find(messageId);
m.seen = seen;
m.flagged = flagged;
m.answered = answered;
m.deleted = deleted;
}
@Override
public synchronized long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged,
boolean answered, boolean deleted, Instant internalDate) {
Stored stored = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), raw,
internalDate == null ? Instant.now() : internalDate);
stored.seen = seen;
stored.flagged = flagged;
stored.answered = answered;
stored.deleted = deleted;
messages.add(stored);
return stored.uid;
}
@Override
public synchronized List<Long> copy(long userId, List<Entry> list, Mailbox target) {
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Stored from = find(entry.id());
Stored copy = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), from.raw, from.date);
copy.seen = from.seen;
copy.flagged = from.flagged;
messages.add(copy);
uids.add(copy.uid);
}
return uids;
}
@Override
public synchronized List<Long> move(long userId, List<Entry> list, Mailbox target) {
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Stored m = find(entry.id());
m.folderId = target.id();
m.uid = nextUid(target.id());
uids.add(m.uid);
}
return uids;
}
@Override
public void expunge(List<Long> messageIds) {
messages.removeIf(m -> messageIds.contains(m.id));
}
}
@@ -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());
}
}
@@ -8,11 +8,18 @@ import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.Socket; import java.net.Socket;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
@@ -214,15 +221,131 @@ class SmtpServerTest {
assertThat(SmtpServer.path("<a b@c>")).isNull(); assertThat(SmtpServer.path("<a b@c>")).isNull();
} }
// ---------- TLS (75.1) ----------
@TempDir
Path tlsDir;
private SmtpServer tlsServer(boolean implicit) throws Exception {
server.stop();
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
Duration.ofSeconds(30), SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), implicit), backend);
server.start();
return server;
}
@Test
void startTlsThenAuthAndTheSessionStartsOver() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
List<String> plain = client.readReplyLines();
assertThat(plain).contains("250-STARTTLS").noneMatch(line -> line.contains("AUTH"));
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("250");
assertThat(client.send("STARTTLS")).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
// RFC 3207: MAIL FROM before STARTTLS is forgotten, EHLO again
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("503");
client.send("EHLO laptop");
List<String> secure = client.readReplyLines();
assertThat(secure).contains("250-AUTH PLAIN LOGIN").noneMatch(line -> line.contains("STARTTLS"));
assertThat(client.send("STARTTLS")).startsWith("503");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("235");
client.send("MAIL FROM:<alice@example.test>");
client.send("RCPT TO:<friend@gmail.com>");
client.send("DATA");
assertThat(client.send("Subject: tls\r\n\r\nhi\r\n.")).startsWith("250");
}
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
// CVE-2011-0411: commands sent in plain text right after STARTTLS must not run inside the TLS session
@Test
void plainTextInjectedAfterStartTlsIsNotExecuted() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
client.write("STARTTLS\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\n");
assertThat(client.read()).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
String reply = client.read();
assertThat(reply == null || reply.startsWith("421")).as(reply).isTrue();
}
assertThat(backend.envelopes).isEmpty();
}
@Test
void realClientRequiringStartTlsDelivers() throws Exception {
tlsServer(false);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over STARTTLS");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.envelopes).hasSize(1);
assertThat(backend.envelopes.get(0).authenticatedUser()).isEqualTo("alice@example.test");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void implicitTlsPortWorksWithoutStartTls() throws Exception {
tlsServer(true);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over 465");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void selfSignedCertificateIsCreatedOnceAndReused() throws Exception {
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
String first = java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE));
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE))).isEqualTo(first);
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.KEY_FILE))).startsWith("-----BEGIN PRIVATE KEY-----");
}
private static String b64(String text) { private static String b64(String text) {
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
} }
/** Line-oriented test client. */ /** Line-oriented test client. */
static final class Client implements AutoCloseable { static final class Client implements AutoCloseable {
private final Socket socket; private Socket socket;
private final BufferedReader in; private BufferedReader in;
private final OutputStream out; private OutputStream out;
Client(int port) throws Exception { Client(int port) throws Exception {
socket = new Socket("127.0.0.1", port); socket = new Socket("127.0.0.1", port);
@@ -231,6 +354,28 @@ class SmtpServerTest {
out = socket.getOutputStream(); out = socket.getOutputStream();
} }
/** Upgrade the connection to TLS (any certificate is trusted — the test server's is self-signed). */
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
void write(String text) throws Exception { void write(String text) throws Exception {
out.write(text.getBytes(StandardCharsets.UTF_8)); out.write(text.getBytes(StandardCharsets.UTF_8));
out.flush(); out.flush();
+7 -2
View File
@@ -2,7 +2,9 @@
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain. # 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). # 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. # Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
# The first mailbox (admin@<MAIL_DOMAINS>) and its password: in the "mail" logs on the first start. # Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
name: reactive-mail name: reactive-mail
services: services:
@@ -55,13 +57,16 @@ services:
expose: expose:
- "8080" # web client + API → domain in Jambotron - "8080" # web client + API → domain in Jambotron
- "3025" # SMTP, inside the network only - "3025" # SMTP, inside the network only
- "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port
environment: environment:
DB_HOST: db DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test} MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test} DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-} # 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" OUTBOUND_MODE: "off"
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
SPF_ENABLED: "false" SPF_ENABLED: "false"
+1
View File
@@ -0,0 +1 @@
@jambotron:registry=https://gitea.jambotron.com/api/packages/jambotron_gang/npm/
+5 -4
View File
@@ -50,6 +50,7 @@
} }
], ],
"styles": [ "styles": [
"src/material.scss",
"src/styles.css" "src/styles.css"
] ]
}, },
@@ -58,13 +59,13 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "1MB",
"maximumError": "1MB" "maximumError": "2MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "8kB",
"maximumError": "8kB" "maximumError": "16kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"
+4
View File
@@ -11,12 +11,16 @@
"private": true, "private": true,
"packageManager": "npm@11.17.0", "packageManager": "npm@11.17.0",
"dependencies": { "dependencies": {
"@angular/aria": "^22.2.6",
"@angular/cdk": "^22.2.6",
"@angular/common": "^22.2.0", "@angular/common": "^22.2.0",
"@angular/compiler": "^22.2.0", "@angular/compiler": "^22.2.0",
"@angular/core": "^22.2.0", "@angular/core": "^22.2.0",
"@angular/forms": "^22.2.0", "@angular/forms": "^22.2.0",
"@angular/material": "^22.2.6",
"@angular/platform-browser": "^22.2.0", "@angular/platform-browser": "^22.2.0",
"@angular/router": "^22.2.0", "@angular/router": "^22.2.0",
"@jambotron/aria-grid": "0.1.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"
}, },
+6 -4
View File
@@ -1,13 +1,15 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; 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 { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes'; 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 = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideBrowserGlobalErrorListeners(), provideBrowserGlobalErrorListeners(),
provideRouter(routes), provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([authInterceptor])) provideHttpClient(withInterceptors([authInterceptor])),
provideMailGrid()
] ]
}; };
+15 -35
View File
@@ -1,43 +1,23 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard, guestGuard } from './core/auth';
export const routes: Routes = [ export const routes: Routes = [
{ { path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
path: 'login', { path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
loadComponent: () => import('./components/login/login.component'),
},
{
path: 'register',
loadComponent: () => import('./components/register/register.component'),
},
{ {
path: '', path: '',
loadComponent: () => import('./components/layout/layout.component'), canActivate: [authGuard],
loadComponent: () => import('./pages/shell/shell.component').then(m => m.ShellComponent),
children: [ children: [
{ { path: '', pathMatch: 'full', loadComponent: () => import('./pages/inbox-redirect').then(m => m.InboxRedirect) },
path: 'inbox', { path: 'folder/:id', loadComponent: () => import('./pages/folder/folder.page').then(m => m.FolderPage) },
loadComponent: () => import('./components/inbox/inbox.component'), { 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: 'compose', { path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
loadComponent: () => import('./components/compose/compose.component'), { path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) }
}, ]
{
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: '**', redirectTo: '' }
]; ];
+9 -5
View File
@@ -1,10 +1,14 @@
import { Component } from '@angular/core'; import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { OverlaysComponent } from './ui/overlays.component';
import { ThemeService } from './ui/theme.service';
@Component({ @Component({
imports: [RouterOutlet], imports: [RouterOutlet, OverlaysComponent],
selector: 'app-root', selector: 'app-root',
template: `<router-outlet />`, template: `<router-outlet /><app-overlays />`
styles: ``
}) })
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, &nbsp;code&nbsp;</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) {
&nbsp;•&nbsp;
}
@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 &copy; 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;
}
});
}
}
+77
View File
@@ -0,0 +1,77 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
/** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' })
export class ApiService {
private http = inject(HttpClient);
config(): Observable<ServerConfig> { return this.http.get<ServerConfig>('/api/config'); }
// profile
me(): Observable<Profile> { return this.http.get<Profile>('/api/me'); }
updateMe(displayName: string): Observable<Profile> { return this.http.put<Profile>('/api/me', { displayName }); }
changePassword(currentPassword: string, newPassword: string): Observable<unknown> {
return this.http.put('/api/me/password', { currentPassword, newPassword });
}
// folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
renameFolder(id: number, name: string): Observable<unknown> { return this.http.put(`/api/folders/${id}`, { name }); }
deleteFolder(id: number): Observable<unknown> { return this.http.delete(`/api/folders/${id}`); }
emptyFolder(id: number): Observable<{ deleted: number }> {
return this.http.delete<{ deleted: number }>(`/api/folders/${id}/messages`);
}
// messages
messages(folderId: number): Observable<Message[]> {
return this.http.get<Message[]>('/api/messages', { params: { folderId } });
}
search(folderId: number, keyword: string): Observable<Message[]> {
return this.http.get<Message[]>('/api/messages/search', { params: { folderId, keyword } });
}
message(id: number): Observable<Message> { return this.http.get<Message>(`/api/messages/${id}`); }
updateMessage(id: number, change: { read?: boolean; flagged?: boolean; folderId?: number }): Observable<Message> {
return this.http.patch<Message>(`/api/messages/${id}`, change);
}
deleteMessage(id: number): Observable<unknown> { return this.http.delete(`/api/messages/${id}`); }
// compose
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); }
updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
send(draft: Draft, draftId: number | null): Observable<Message> {
return this.http.post<Message>('/api/send', { ...draft, draftId });
}
// attachments
attachments(messageId: number): Observable<Attachment[]> {
return this.http.get<Attachment[]>(`/api/messages/${messageId}/attachments`);
}
upload(messageId: number, file: File): Observable<Attachment> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<Attachment>(`/api/messages/${messageId}/attachments`, form);
}
deleteAttachment(id: number): Observable<unknown> { return this.http.delete(`/api/attachments/${id}`); }
download(id: number): Observable<Blob> {
return this.http.get(`/api/attachments/${id}/download`, { responseType: 'blob' });
}
// contacts
contacts(): Observable<Contact[]> { return this.http.get<Contact[]>('/api/contacts'); }
addContact(contact: Contact): Observable<unknown> { return this.http.post('/api/contacts', contact); }
updateContact(id: number, contact: Contact): Observable<unknown> { return this.http.put(`/api/contacts/${id}`, contact); }
deleteContact(id: number): Observable<unknown> { return this.http.delete(`/api/contacts/${id}`); }
// blacklist
blacklist(): Observable<BlacklistEntry[]> { return this.http.get<BlacklistEntry[]>('/api/spam/blacklist'); }
block(senderEmail: string, reason: string): Observable<unknown> {
return this.http.post('/api/spam/blacklist', { senderEmail, reason });
}
unblock(senderEmail: string): Observable<unknown> {
return this.http.delete('/api/spam/blacklist', { params: { senderEmail } });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import { Observable, tap } from 'rxjs';
interface TokenResponse { token: string; email: string; }
/** JWT in localStorage; who is logged in. */
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private router = inject(Router);
readonly email = signal<string | null>(read('email'));
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
login(email: string, password: string): Observable<TokenResponse> {
return this.http.post<TokenResponse>('/api/login', { email, password }).pipe(tap(r => this.store(r)));
}
register(email: string, password: string, displayName: string): Observable<TokenResponse> {
return this.http.post<TokenResponse>('/api/register', { email, password, displayName }).pipe(tap(r => this.store(r)));
}
token(): string | null { return read('token'); }
logout(): void {
this.clear();
this.router.navigate(['/login']);
}
clear(): void {
try {
localStorage.removeItem('token');
localStorage.removeItem('email');
} catch { /* storage blocked */ }
this.email.set(null);
}
private store(response: TokenResponse): void {
try {
localStorage.setItem('token', response.token);
localStorage.setItem('email', response.email);
} catch { /* storage blocked: logged in for this tab only */ }
this.email.set(response.email);
}
}
function read(key: string): string | null {
try { return localStorage.getItem(key); } catch { return null; }
}
+28
View File
@@ -0,0 +1,28 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token();
const request = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
return next(request).pipe(
catchError((error: unknown) => {
// not logged in or the token expired → to the login form
if (error instanceof HttpErrorResponse && error.status === 401 && !req.url.endsWith('/login')) {
auth.clear();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
export const authGuard: CanActivateFn = () =>
inject(AuthService).loggedIn() ? true : inject(Router).createUrlTree(['/login']);
export const guestGuard: CanActivateFn = () =>
inject(AuthService).loggedIn() ? inject(Router).createUrlTree(['/']) : true;
@@ -0,0 +1,35 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { ApiService } from './api.service';
import { Folder, Profile } from './models';
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
@Injectable({ providedIn: 'root' })
export class MailStateService {
private api = inject(ApiService);
readonly folders = signal<Folder[]>([]);
readonly profile = signal<Profile | null>(null);
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
system(name: string): Folder | null {
return this.folders().find(f => f.system && f.name === name) ?? null;
}
folder(id: number): Folder | null {
return this.folders().find(f => f.id === id) ?? null;
}
refreshFolders(): void {
this.api.folders().subscribe({ next: folders => this.folders.set(folders), error: () => {} });
}
refreshProfile(): void {
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
}
reset(): void {
this.folders.set([]);
this.profile.set(null);
}
}
+108
View File
@@ -0,0 +1,108 @@
export interface Folder {
id: number;
name: string;
system: boolean;
messageCount: number;
unreadCount: number;
}
export interface Message {
id: number;
folderId: number;
senderEmail: string;
senderName?: string | null;
recipientEmail: string;
ccEmail?: string | null;
subject: string;
body: string;
html: boolean;
read: boolean;
flagged: boolean;
spam: boolean;
messageSize?: number | null;
receivedAt: string;
hasAttachments: boolean;
attachmentCount: number;
}
export interface Attachment {
id: number;
filename: string;
contentType: string;
sizeBytes: number;
}
export interface Contact {
id?: number;
name: string;
email: string;
phone?: string;
company?: string;
}
export interface BlacklistEntry {
id: number;
senderEmail: string;
reason?: string | null;
createdAt?: string;
}
export interface Profile {
email: string;
displayName: string;
usedBytes: number;
quotaBytes: number;
createdAt: string;
}
export interface ServerConfig {
registrationEnabled: boolean;
domains: string[];
}
export interface Draft {
to: string;
cc: string;
subject: string;
body: string;
html: boolean;
}
/** System folders: label and icon in the web client. */
export const FOLDER_VIEW: Record<string, { label: string; icon: string }> = {
INBOX: { label: 'Inbox', icon: 'inbox' },
Drafts: { label: 'Drafts', icon: 'edit_note' },
Sent: { label: 'Sent', icon: 'send' },
Spam: { label: 'Spam', icon: 'report' },
Trash: { label: 'Trash', icon: 'delete' }
};
export function folderLabel(folder: Folder): string {
return folder.system ? (FOLDER_VIEW[folder.name]?.label ?? folder.name) : folder.name;
}
export function folderIcon(folder: Folder): string {
return folder.system ? (FOLDER_VIEW[folder.name]?.icon ?? 'folder') : 'folder';
}
export function formatBytes(bytes: number | null | undefined): string {
const value = bytes ?? 0;
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
return `${(value / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
/** Text of an API error: our {error}, Spring's {message}, or the status. */
export function errorText(error: unknown): string {
const e = error as { error?: { error?: string; message?: string; status?: number } | string; status?: number; message?: string };
if (e?.error && typeof e.error === 'object') {
// Spring's error body: {status, error: "Bad Request", message: "No such mailbox: …"} — the reason is in message
const body = e.error;
const springBody = typeof body.status === 'number';
return (springBody ? body.message || body.error : body.error || body.message) || `Error ${e.status ?? ''}`.trim();
}
if (typeof e?.error === 'string' && e.error) return e.error;
if (e?.status === 0) return 'Server is not reachable';
return e?.message || 'Something went wrong';
}
@@ -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);
};
-73
View File
@@ -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('');
}
+80
View File
@@ -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">&#64;</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();
}
}
+25
View File
@@ -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">&lt;{{ m.senderEmail }}&gt;</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();
}
}
-57
View File
@@ -1,57 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, BehaviorSubject } from 'rxjs';
import { tap, shareReplay } from 'rxjs/operators';
import { LoginRequest, LoginResponse, RegisterRequest } from '../models/mail.models';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private http = inject(HttpClient);
private apiUrl = '/api';
private currentUserSubject = new BehaviorSubject<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');
}
}
-113
View File
@@ -1,113 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Message, Folder, Attachment, SendRequest, BlacklistEntry, PageResponse, Contact } from '../models/mail.models';
@Injectable({
providedIn: 'root'
})
export class MailService {
private http = inject(HttpClient);
private apiUrl = '/api';
// Папки
getFolders(): Observable<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 {}
+43
View File
@@ -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 });
}
}
+26
View File
@@ -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)
};
}
}];
}
+93
View File
@@ -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); }
}
+36
View File
@@ -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 */ }
}
+24
View File
@@ -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);
}
}
+4 -6
View File
@@ -1,19 +1,17 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="theme-cyber-magenta">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>MailServer — Synthwave Edition</title> <title>Synth Mail</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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 href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
</head> </head>
<body class="bg-synth-dark"> <body>
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>
+62
View File
@@ -0,0 +1,62 @@
// Material (checkboxes, buttons, tooltips of the grid) in the synthwave colors + @jambotron/aria-grid styles
@use '@angular/material' as mat;
@use '@jambotron/aria-grid/styles/aria-grid';
html {
color-scheme: dark;
@include mat.theme((
color: (primary: mat.$cyan-palette, tertiary: mat.$magenta-palette, theme-type: dark),
typography: (plain-family: 'Rajdhani, sans-serif', brand-family: 'Orbitron, sans-serif'),
density: -1
));
@include mat.checkbox-overrides((
selected-icon-color: var(--secondary-color),
selected-hover-icon-color: var(--secondary-color),
selected-focus-icon-color: var(--secondary-color),
selected-checkmark-color: #0d0221
));
@include mat.tooltip-overrides((container-color: #1a1033, supporting-text-color: #e2e8f0));
}
// grid tokens → synthwave
:root {
--jt-accent: var(--secondary-color);
--jt-accent-text: var(--secondary-color);
--jt-on-accent: #0d0221;
--jt-danger: #ff4d6d;
--jt-danger-text: #ff8fa3;
--jt-text: #e2e8f0;
--jt-text-muted: #94a3b8;
--jt-text-disabled: rgba(226, 232, 240, 0.4);
--jt-divider: rgba(0, 240, 255, 0.14);
--jt-field-bg: rgba(2, 6, 23, 0.6);
--jt-field-border: rgba(0, 240, 255, 0.35);
--jt-focus-ring: 0 0 0 3px var(--highlight-color);
--jt-glow-accent: 0 0 12px var(--glow-color);
--jt-popup-bg: rgba(10, 5, 24, 0.97);
--jt-popup-border: rgba(255, 0, 127, 0.45);
--jt-shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 20px rgba(255, 0, 127, 0.25);
--jt-radius-sm: 8px;
--jt-radius-md: 12px;
}
.jt-table {
font-family: 'Rajdhani', sans-serif;
font-size: 0.95rem;
th {
font-family: 'Orbitron', sans-serif;
font-size: 0.68rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--secondary-color);
}
tbody tr:hover td {
background: rgba(0, 240, 255, 0.06);
}
}
.mat-icon {
vertical-align: middle;
}
+35 -62
View File
@@ -1,5 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800;900&family=Rajdhani:wght@400;500;600;700&family=Fira+Code:wght@400;500;600&display=swap');
@import "tailwindcss"; @import "tailwindcss";
@theme { @theme {
@@ -11,9 +9,9 @@
--color-synth-yellow: #ffbe0b; --color-synth-yellow: #ffbe0b;
--color-synth-glass: rgba(15, 12, 35, 0.65); --color-synth-glass: rgba(15, 12, 35, 0.65);
--font-family-orbitron: 'Orbitron', sans-serif; --font-orbitron: 'Orbitron', sans-serif;
--font-family-rajdhani: 'Rajdhani', sans-serif; --font-rajdhani: 'Rajdhani', sans-serif;
--font-family-fira-code: 'Fira Code', monospace; --font-mono: 'Fira Code', monospace;
} }
:root { :root {
@@ -81,12 +79,7 @@
box-shadow: 0 0 15px var(--secondary-color) !important; box-shadow: 0 0 15px var(--secondary-color) !important;
} }
* { @layer components {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
background-color: var(--bg-color); background-color: var(--bg-color);
color: #f1f5f9; color: #f1f5f9;
@@ -291,61 +284,41 @@ body {
box-shadow: 0 0 10px var(--secondary-color), 0 0 20px var(--secondary-color); box-shadow: 0 0 10px var(--secondary-color), 0 0 20px var(--secondary-color);
} }
/* Light theme overrides */ /* App pieces on top of the showcase */
.light-theme { .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
--bg-color: #f0f0f5;
--synth-glass: rgba(255, 255, 255, 0.7);
}
.light-theme body, .synth-input {
.light-theme { width: 100%;
background-color: #f0f0f5 !important; background: rgba(2, 6, 23, 0.8);
color: #1a1a2e !important; border: 1px solid rgba(0, 240, 255, 0.4);
border-radius: 10px;
padding: 0.55rem 0.85rem;
color: #e2e8f0;
font-family: 'Rajdhani', sans-serif;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
} }
.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); }
.synth-input::placeholder { color: #64748b; }
.synth-label { display: block; font-family: 'Fira Code', monospace; font-size: 0.72rem; color: var(--primary-color); margin-bottom: 0.3rem; letter-spacing: 0.04em; }
.light-theme .glass-panel { .ghost-btn {
background: rgba(255, 255, 255, 0.8) !important; display: inline-flex; align-items: center; gap: 0.35rem;
border-color: rgba(121, 40, 202, 0.3) !important; padding: 0.4rem 0.75rem; border-radius: 10px;
color: #1a1a2e !important; border: 1px solid rgba(0, 240, 255, 0.35); background: rgba(15, 23, 42, 0.7);
color: #cbd5e1; font-family: 'Fira Code', monospace; font-size: 0.75rem;
transition: all 0.2s;
} }
.ghost-btn:hover:not(:disabled) { color: var(--secondary-color); border-color: var(--secondary-color); box-shadow: 0 0 10px var(--glow-color); }
.ghost-btn:focus-visible, .cyber-btn:focus-visible { outline: 3px solid var(--highlight-color); outline-offset: 2px; }
.ghost-btn:disabled, .cyber-btn:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }
.ghost-btn.danger { border-color: rgba(255, 77, 109, 0.5); color: #ff8fa3; }
.ghost-btn.danger:hover:not(:disabled) { color: #fff; background: rgba(255, 77, 109, 0.25); box-shadow: 0 0 10px rgba(255, 77, 109, 0.6); }
.cyber-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; }
.cyber-btn .mat-icon, .ghost-btn .mat-icon { font-size: 18px; width: 18px; height: 18px; }
.light-theme .synth-stage { .glass-panel.static:hover { border-color: rgba(0, 240, 255, 0.25); box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 15px rgba(255, 255, 255, 0.03); }
display: none !important;
}
.light-theme .neon-text-primary, .no-anim .grid-plane { animation: none; }
.light-theme .neon-text-secondary { @media (prefers-reduced-motion: reduce) { .grid-plane { animation: none; } }
text-shadow: none !important;
color: #1a1a2e !important;
}
.light-theme input,
.light-theme .cyber-btn {
background: rgba(255, 255, 255, 0.9) !important;
color: #1a1a2e !important;
border-color: rgba(121, 40, 202, 0.4) !important;
}
.light-theme .cyber-btn:hover {
background: var(--accent-color) !important;
color: #fff !important;
}
.light-theme a,
.light-theme span,
.light-theme p,
.light-theme h1,
.light-theme h2,
.light-theme label {
color: #1a1a2e !important;
}
.light-theme .text-slate-200,
.light-theme .text-slate-300,
.light-theme .text-slate-400 {
color: #333 !important;
}
.light-theme .bg-synth-darker\/80 {
background-color: rgba(255, 255, 255, 0.9) !important;
} }