Compare commits

..
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 99e12d33f3 Вход через Jambotron (OAuth 2.0 code + PKCE), пароли приложений, системные ящики
continuous-integration/drone/pr Build is passing
- /api/oauth/start → JAMBOTRON_URL/oauth/authorize (client mail, PKCE S256);
  /api/oauth/callback: код меняется на сервере у Jambotron /api/oauth/token
  (Bearer SERVICE_TOKEN) → рабочий ящик сотрудника → сессия, ADMIN Jambotron — ADMIN.
- Вход по паролю в веб — только встроенный администратор (когда вход через Jambotron включён).
- Пароли приложений (V9 app_passwords): User panel → APP PASSWORDS, только SMTP/IMAP.
- Системные ящики (V9 users.is_system): без входа в веб, открывают встроенные
  администраторы, не в USERS; Admin panel → MAILBOXES → кнопка SYSTEM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:57:30 +01:00
Liosha84 910a028612 Merge pull request 'Роли ADMIN и USER, User panel и Admin panel' (#33) from feat/roles-panels into master
continuous-integration/drone/push Build is passing
Reviewed-on: #33
2026-11-07 20:20:33 +01:00
Liosha84andClaude Opus 5.5 cf87b60b38 Роли ADMIN и USER, User panel и Admin panel
continuous-integration/drone/pr Build is passing
- Роль ADMIN = users.is_admin; встроенный администратор (mailserver.admins, по
  умолчанию admin@) всегда ADMIN и получает роль в базе при старте.
- PUT /api/admin/mailboxes/{id}/role {ADMIN|USER}: только ADMIN; не свою, не
  встроенного, общий ящик не может быть ADMIN.
- Admin panel (/admin, только ADMIN): USERS (логины и роли, MAKE ADMIN / MAKE USER),
  MAILBOXES, LISTS. User panel (/settings): профиль, пароль, хранилище, вид, мои ящики.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:18:18 +01:00
Liosha84 402d2c403b Merge pull request 'Левая панель: комбобокс ящиков; добавление — в Настройки; выделение в цвете темы' (#32) from feat/mailbox-combobox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #32
2026-11-07 20:12:14 +01:00
Liosha84 8eb69b32e5 Merge pull request 'Селекты в тёмной теме: выпадающий список не белый' (#31) from fix/select-theme into master
continuous-integration/drone/push Build is passing
Reviewed-on: #31
2026-11-07 20:12:01 +01:00
Liosha84andClaude Opus 5.5 3d9fc77201 Левая панель: комбобокс ящиков; добавление ящиков — в Настройки; выделение в цвете темы
continuous-integration/drone/pr Build is passing
- MAILBOX: <select> со своими, общими и добавленными ящиками (непрочитанные в скобках),
  выбор — переключение; список и форма «добавить» из левой панели убраны.
- Settings → MY MAILBOXES (у всех): список ящиков, «убрать» добавленный, «добавить» по паролю.
- Выбранная папка, ссылка и палитра — nav-active в --primary-color темы
  (было фиксированное розовое — в Neon Cyan баклажановое).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:10:54 +01:00
Liosha84andClaude Opus 5.5 1076cd4f3f Селекты в тёмной теме: выпадающий список не белый
continuous-integration/drone/pr Build is passing
Открытый список <select> рисует браузер: без color-scheme он белый, а текст
наш светлый — не читается (домен в MAILBOXES, «Переместить», тема).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:02:28 +01:00
Liosha84 315402edc8 Merge pull request 'Администраторы почты из Jambotron: каждый ADMIN управляет ящиками' (#30) from feat/admins-from-jambotron into master
continuous-integration/drone/push Build is passing
Reviewed-on: #30
2026-11-07 19:46:46 +01:00
Liosha84andClaude Opus 5.5 7e09282a88 Администраторы почты из Jambotron: каждый ADMIN управляет ящиками
continuous-integration/drone/pr Build is passing
users.is_admin (V8) — ставит только служебное API (PUT /api/service/accounts
с "admin"), ящик сам себя администратором не сделает. MailAdmins: список из
конфигурации + выданные Jambotron (загружаются при старте, меняются сразу).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:44:32 +01:00
Liosha84 01c1e5504a Merge pull request 'Settings → MAILBOXES: удалить ящик вместе с письмами' (#29) from feat/delete-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #29
2026-11-07 18:49:23 +01:00
Liosha84andClaude Opus 5.5 e1bc0792ea Settings → MAILBOXES: удалить ящик вместе с письмами
continuous-integration/drone/pr Build is passing
DELETE /api/admin/mailboxes/{id} (только администратор, не свой ящик):
файлы вложений, затем строка users — папки, письма, исходники, контакты,
участие в рассылках и доступы уходят каскадом. Кнопка с подтверждением.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:48:09 +01:00
Liosha84 53c80c57d7 Merge pull request 'secrets-init: SERVICE_TOKEN читаемый для сервера (после #26 падал на старте)' (#28) from fix/service-token-readable into master
continuous-integration/drone/push Build is passing
Reviewed-on: #28
2026-11-07 18:20:45 +01:00
Liosha84andClaude Opus 5.5 7b7de905f9 secrets-init: SERVICE_TOKEN читаемый для сервера (как DB_PASSWORD, JWT_SECRET)
continuous-integration/drone/pr Build is passing
Без этого после #26 сервер не стартует: AccessDeniedException /run/mail-secrets/SERVICE_TOKEN
(на prod поправлено руками chmod 644 в томе 2026-11-07).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:27:23 +01:00
Liosha84 a26e8fe3dd Merge pull request 'Несколько ящиков у одного логина, общие ящики, «кто ответил», вход ящик*логин' (#27) from feat/multi-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #27
2026-11-07 14:45:24 +01:00
Liosha84 80d2988fa3 Merge pull request 'Рассылки, папки по +метке, TRAP только для чужих адресов, служебное API для Jambotron' (#26) from feat/lists-tags-service-api into master
continuous-integration/drone/push Build is passing
Reviewed-on: #26
2026-11-07 14:45:12 +01:00
Liosha84andClaude Opus 5.5 c765c51cf0 Несколько ящиков у одного логина, общие ящики, кто ответил, вход ящик*логин
continuous-integration/drone/pr Build is passing
- V7: users.is_shared, mailbox_access (MEMBER от админа/Jambotron, ATTACHED по паролю ящика), messages.answered_by
- /api/mailboxes: мои ящики с непрочитанными, сессия другого ящика (JWT с claim by), подключить по паролю
  (LoginGuard), убрать из меню
- общий ящик: без своего входа (веб/SMTP/IMAP), участники открывают; «прочитано» общее; ответ из веба → answered_by
- SMTP/IMAP: ящик*логин + пароль логина (как master user в Dovecot)
- открытый чужой ящик (общий, подключённый, OPEN админа): профиль и пароль не меняются (403)
- админ: создать shared, участники ящика (PUT /{id}/members); служебное API: PUT /shared/{address};
  выключение аккаунта убирает и из общих ящиков
- UI: MAILBOXES в левом меню (переключение, ⊕ подключить, × убрать), shared/участники в настройках,
  «Answered by» у письма
- 88 тестов; живой прогон на Postgres 17 (апгрейд V6→V7 на данных), скриншоты UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:27:11 +01:00
Liosha84andClaude Opus 5.5 cf84797e68 Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing
- V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки;
  закрытая рассылка — только от вошедших отправителей (550 снаружи)
- bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя)
- TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются
- /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки
- MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах
- Настройки → LISTS для администраторов
- 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 14:15:24 +01:00
Liosha84 3603b9d315 Merge pull request 'Ловушка у ящика (TRAP): staging@ отправляет только себе в INBOX' (#25) from feat/trap-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #25
2026-11-07 11:00:09 +01:00
Liosha84andClaude Opus 5.5 fc3fe3970d Ловушка у ящика (TRAP): что ящик отправляет — в его же INBOX, наружу ничего
continuous-integration/drone/pr Build is passing
Для staging@: staging-стенд шлёт через настоящий mail.jambocheck.com,
но тестовые письма не доходят до живых людей.
- V5: users.is_trap
- MailRouter: отправитель-ловушка → все получатели LOCAL, одна копия в INBOX отправителя
- PUT /api/admin/mailboxes/{id}/trap, MailboxView.trap
- MAILBOXES: кнопка outbox/move_to_inbox, метка TRAP
- тесты: router + admin (77 тестов)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:45:20 +01:00
Liosha84 0811dfcf68 Merge pull request 'Ящики: пароль от 8 символов (было 10)' (#24) from fix/mailbox-password-8 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #24
2026-11-07 04:00:33 +01:00
Liosha84andClaude Opus 5.5 714c62f285 Ящики: минимальная длина пароля 8 (как при регистрации и смене пароля), было 10
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:51:47 +01:00
Liosha84 e37487d509 Merge pull request 'Веб-почта: MAILBOXES на две колонки; левое меню вровень с панелью' (#23) from fix/layout-leftovers into master
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2026-11-07 03:41:44 +01:00
Liosha84andClaude Opus 5.5 312e65fc33 Веб-почта: левое меню вровень с панелью — sticky top 96 → 80 px
continuous-integration/drone/pr Build is passing
Без прокрутки меню стоит на ~86 px, а sticky top-24 (96 px) сдвигал его ниже панели.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 5c08d7e361 Веб-почта: левое меню — верхний отступ как у панели посередине (md: 24 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 ccd31c503e Веб-почта: MAILBOXES на две колонки (col-span на элементе компонента — он и есть ячейка сетки)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84 532f6ccab8 Merge pull request 'Веб-почта: колонка Subject ещё на ~1 см уже (520 → 480 px)' (#22) from fix/subject-width-2 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #22
2026-11-07 03:25:56 +01:00
Liosha84andClaude Opus 5.5 9658cdfcd7 Веб-почта: колонка Subject ещё на ~1 см уже (520 → 480 px)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:21:15 +01:00
Liosha84 44c8e17a27 Merge pull request 'Администратор открывает любой ящик (OPEN); @домен при создании ящика; «показать пароль» везде' (#21) from feat/admin-open-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #21
2026-11-07 03:12:23 +01:00
Liosha84andClaude Opus 5.5 aedda06f2e Веб-почта: при создании ящика — только имя, @домен подставляется (выбор, если доменов несколько); глазок «показать пароль» у всех полей пароля
continuous-integration/drone/pr Build is passing
Регистрация, смена пароля, создание ящика (вход уже был).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:11:42 +01:00
Liosha84andClaude Opus 5.5 91bb8b9b2d Администратор открывает любой ящик: MAILBOXES → OPEN, полоса «вернуться в admin@»
continuous-integration/drone/pr Build is passing
POST /api/admin/mailboxes/{id}/open — сессия этого ящика без его пароля, только для
MAIL_ADMINS; каждое открытие — в лог (кто какой ящик открыл). В веб-почте: кнопка OPEN
у ящика, сверху розовая полоса «Вы в ящике X как администратор — Back to admin@».
Тесты: +1 (всего 75), ng build OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:09:29 +01:00
Liosha84 18ee4820c1 Merge pull request 'Веб-почта: колонка Subject на ~1 см уже — без горизонтальной прокрутки' (#20) from fix/subject-width into master
continuous-integration/drone/push Build is passing
Reviewed-on: #20
2026-11-07 03:03:15 +01:00
Liosha84andClaude Opus 5.5 59e2c11d6a Веб-почта: колонка Subject на ~1 см уже (560 → 520 px) — без горизонтальной прокрутки
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:02:31 +01:00
Liosha84 84ae0b6c7d Merge pull request 'README: начальный пароль admin@ — из файла, не из лога' (#19) from docs/password-file-not-log into master
continuous-integration/drone/push Build is passing
Reviewed-on: #19
2026-11-07 02:50:58 +01:00
Liosha84andClaude Opus 5.5 0d0f4800f0 README: пароль admin@jambocheck.com (по решению владельца)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:22 +01:00
Liosha84andClaude Opus 5.5 b009af972a README: где пароли ящиков — SECRETS.md закрытого репозитория Jambotron (этот репозиторий публичный)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:08 +01:00
Liosha84andClaude Opus 5.5 100ab741f3 README: начальный пароль admin@ — из файла в томе, не из лога (лог пропадает при передеплое)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:49:14 +01:00
Liosha84 bdca471b97 Merge pull request 'README: «Забыл пароль — где взять» первым разделом' (#18) from docs/forgot-admin-password into master
continuous-integration/drone/push Build is passing
Reviewed-on: #18
2026-11-07 02:41:25 +01:00
Liosha84andClaude Opus 5.5 639ab0a6e9 README: первым разделом — забыл пароль, где взять (admin@ из логов, сброс, MAILBOXES)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:40:52 +01:00
Liosha84 f1d66d4c7d Merge pull request 'Веб-почта по HTTPS: https://mail.jambocheck.com (порт 443)' (#17) from feat/https-443 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2026-11-07 02:22:03 +01:00
72 changed files with 3089 additions and 140 deletions
+32 -1
View File
@@ -2,7 +2,38 @@
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC. Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend. Назначение — наша настоящая почта **https://mail.jambocheck.com** (prod Jambotron, системная ВМ, адрес `103.109.235.154`) и тестовый проект на Jambotron.
## Забыл пароль — где взять
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб) **на чужие адреса**, никому не доставляется: одна копия ложится в его же INBOX. Письма на наши адреса и рассылки (`alerts-staging@`) доставляются как обычно. Для `staging@` — тестовые письма staging-стенда не попадают к живым людям, а алерты staging доходят до команды.
**Папки по `+метке`:** письмо на `bob+prod@jambocheck.com` ложится в ящик `bob@` в папку **prod** (создаётся сама). В имени ящика и рассылки `+` нельзя. Метка не может быть системной папкой (Spam, Trash …) — тогда INBOX.
**Рассылки (LISTS):** Настройки → LISTS. Письмо на адрес рассылки (`alerts-prod@`) → **каждому участнику своя копия** (своё «прочитано») в папку рассылки. Закрытая рассылка (по умолчанию) принимает письма только от вошедших отправителей (Kuma через `monitor@`, Jambotron) — снаружи 550. Адрес рассылки не может совпадать с ящиком.
**Несколько ящиков у одного логина:** слева в меню **MAILBOXES** — свой ящик, общие, в которых ты участник, и подключённые (кнопка ⊕: адрес + пароль того ящика). Клик — переключиться; «×» — убрать из своего меню.
**Общий ящик** (`support@`, `billing@`): Настройки → MAILBOXES → галочка «shared mailbox» при создании, кнопка `group` — кто может его открывать. Своего пароля и входа у него нет; «прочитано» общее (командный ящик); у письма видно **«Answered by …»** — кто ответил из веб-почты. В профиле открытого чужого ящика пароль/имя не меняются.
**В почтовой программе** (Thunderbird, телефон) общий или подключённый ящик — логин `ящик*свой_логин` (например `support@jambocheck.com*jambotronpw@jambocheck.com`) и **свой** пароль; так и для IMAP, и для SMTP (письма уходят от имени общего ящика).
**Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer <SERVICE_TOKEN>`; токен генерирует `secrets-init` в томе секретов — файл `SERVICE_TOKEN`, без токена API выключено): Общие ящики: `PUT /shared/{address}` `{displayName, members[]}` — создать при отсутствии, точный состав участников; выключение аккаунта убирает его и из общих ящиков.
`PUT /accounts/{email}` `{displayName, active}` — создать (201 + пароль один раз) или изменить; `active:false` — выключить и убрать из всех рассылок · `POST /accounts/{email}/password` — новый пароль один раз · `GET /accounts/{email}` · `GET /lists` · `PUT /lists/{address}` `{folder, description, openToOutside, members[]}` — точный состав · `DELETE /lists/{address}`.
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
| Ситуация | Что делать |
| :--- | :--- |
| Пароль `admin@` ещё не меняли | файл `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет; лежит в томе `data`, передеплой его не стирает). С компьютера: `ssh root@103.109.235.150`, затем `incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt`. В логе `mail` строка `Mailbox created…` есть **только до первого передеплоя** |
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
## Как устроено ## Как устроено
@@ -27,8 +27,9 @@ public class ImapConfig {
@Bean @Bean
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources, MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) { @Value("${mailserver.smtp.hostname:localhost}") String hostname,
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname); com.mailserver.service.MailboxAccess access) {
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname, access);
} }
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@@ -0,0 +1,43 @@
package com.mailserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* «Sign in with Jambotron»: JAMBOTRON_URL (https://jambotron.com) + SERVICE_TOKEN (the same token Jambotron uses for
* the service API — here it proves to Jambotron that the code is exchanged by this mail server). Both set — people log
* in through Jambotron, web password login only for the built-in administrator.
*/
@Component
public class JambotronSso {
private final String url;
private final String token;
private final String publicUrl;
public JambotronSso(@Value("${mailserver.jambotron.url:}") String url,
@Value("${mailserver.service-token:}") String token,
@Value("${mailserver.public-url:}") String publicUrl,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
this.url = url.isBlank() ? null : url.trim().replaceAll("/+$", "");
this.token = token.isBlank() ? null : token.trim();
this.publicUrl = publicUrl.isBlank() ? "https://" + hostname : publicUrl.trim().replaceAll("/+$", "");
}
public boolean enabled() {
return url != null && token != null;
}
public String url() {
return url;
}
public String token() {
return token;
}
/** Where Jambotron sends the browser back: https://mail.jambocheck.com/api/oauth/callback */
public String callback() {
return publicUrl + "/api/oauth/callback";
}
}
@@ -10,13 +10,15 @@ import java.util.stream.Collectors;
/** /**
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first * Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
* mailbox — {@code mailserver.admin.email}, or admin@&lt;first domain&gt;. No role in the database: the list lives in * mailbox — {@code mailserver.admin.email}, or admin@&lt;first domain&gt;; plus mailboxes Jambotron marks as admin
* the configuration, so a stolen mailbox can not make itself an administrator. * through the service API ({@code users.is_admin}, V8 — every Jambotron ADMIN). A mailbox can not make itself an
* administrator: only the configuration or the service token.
*/ */
@Component @Component
public class MailAdmins { public class MailAdmins {
private final Set<String> admins; private final Set<String> admins;
private final Set<String> granted = java.util.concurrent.ConcurrentHashMap.newKeySet();
public MailAdmins(@Value("${mailserver.admins:}") String admins, public MailAdmins(@Value("${mailserver.admins:}") String admins,
@Value("${mailserver.admin.email:}") String firstMailbox, @Value("${mailserver.admin.email:}") String firstMailbox,
@@ -31,6 +33,30 @@ public class MailAdmins {
} }
public boolean isAdmin(String email) { public boolean isAdmin(String email) {
if (email == null) {
return false;
}
String key = email.toLowerCase(Locale.ROOT);
return admins.contains(key) || granted.contains(key);
}
/** Built-in administrators (lower case) — owners of system mailboxes. */
public Set<String> builtIn() {
return admins;
}
/** Built-in administrator from the configuration (admin@ by default): the role can not be taken away. */
public boolean isBuiltIn(String email) {
return email != null && admins.contains(email.toLowerCase(Locale.ROOT)); return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
} }
/** Role ADMIN in users.is_admin (given by an administrator in Admin panel or by Jambotron) — loaded at start, kept here on every change. */
public void grant(String email, boolean admin) {
String key = email.toLowerCase(Locale.ROOT);
if (admin) {
granted.add(key);
} else {
granted.remove(key);
}
}
} }
@@ -0,0 +1,36 @@
package com.mailserver.config;
import com.mailserver.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
/** At start: role ADMIN (users.is_admin) → {@link MailAdmins}; the built-in administrator gets the role in the database too. */
@Component
public class MailAdminsLoader {
private static final Logger log = LoggerFactory.getLogger(MailAdminsLoader.class);
private final UserRepository users;
private final MailAdmins admins;
public MailAdminsLoader(UserRepository users, MailAdmins admins) {
this.users = users;
this.admins = admins;
}
@EventListener(ApplicationReadyEvent.class)
public void load() {
users.findAll()
.concatMap(user -> !user.isAdmin() && admins.isBuiltIn(user.getEmail())
? Mono.fromSupplier(() -> { user.setAdmin(true); return user; }).flatMap(users::save) : Mono.just(user))
.filter(user -> user.isAdmin())
.doOnNext(user -> admins.grant(user.getEmail(), true))
.count()
.subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
e -> log.error("Mail administrators not loaded: {}", e.getMessage()));
}
}
@@ -44,6 +44,24 @@ public class MailDomains {
return domains; return domains;
} }
/**
* Address without its "+tag": {@code bob+prod@x} → {@code bob@x}. Mailbox and list names never contain "+",
* so the part after it is a label chosen by the sender — the folder the message goes to.
*/
public static String baseOf(String address) {
String lower = address.toLowerCase(Locale.ROOT);
int at = lower.lastIndexOf('@');
int plus = lower.indexOf('+');
return plus > 0 && plus < at ? lower.substring(0, plus) + lower.substring(at) : lower;
}
/** The "+tag" of an address, or null: {@code bob+prod@x} → {@code prod}. */
public static String tagOf(String address) {
int at = address.lastIndexOf('@');
int plus = address.indexOf('+');
return plus > 0 && plus < at - 1 ? address.substring(plus + 1, at) : null;
}
public static String domainOf(String address) { public static String domainOf(String address) {
int at = address == null ? -1 : address.lastIndexOf('@'); int at = address == null ? -1 : address.lastIndexOf('@');
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT); return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
@@ -52,6 +52,8 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchange -> exchange .authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll() .pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
.pathMatchers("/api/service/**", "/api/oauth/start", "/api/oauth/callback").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()
@@ -0,0 +1,72 @@
package com.mailserver.controller;
import com.mailserver.service.AppPasswords;
import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
/** User panel → APP PASSWORDS: a password per mail program (Thunderbird, phone), SMTP / IMAP only. */
@RestController
@RequestMapping("/api/app-passwords")
public class AppPasswordsController {
private static final Logger log = LoggerFactory.getLogger(AppPasswordsController.class);
public record CreateRequest(@Size(max = 100) String name) {
}
/** @param password shown once */
public record Created(AppPasswords.AppPassword appPassword, String password) {
}
private final AppPasswords passwords;
private final UserService users;
public AppPasswordsController(AppPasswords passwords, UserService users) {
this.passwords = passwords;
this.users = users;
}
@GetMapping
public Flux<AppPasswords.AppPassword> list(Mono<Authentication> auth) {
return me(auth).flatMapMany(id -> passwords.list(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return me(auth).zipWith(body).flatMap(pair -> {
String name = pair.getT2().name() == null || pair.getT2().name().isBlank() ? "Mail program" : pair.getT2().name().trim();
return passwords.create(pair.getT1(), name)
.flatMap(password -> passwords.list(pair.getT1()).last()
.doOnNext(p -> log.info("App password '{}' created for user {}", name, pair.getT1()))
.map(p -> new Created(p, password)));
});
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
return me(auth).flatMap(userId -> passwords.delete(userId, id))
.flatMap(deleted -> deleted ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such app password")));
}
private Mono<Long> me(Mono<Authentication> auth) {
return auth.map(Authentication::getName).flatMap(users::findByEmail).map(u -> u.getId());
}
}
@@ -57,10 +57,12 @@ public class AuthenticationController {
private final boolean registrationEnabled; private final boolean registrationEnabled;
private final LoginGuard guard; private final LoginGuard guard;
private final MailAdmins admins; private final MailAdmins admins;
private final com.mailserver.config.JambotronSso sso;
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled, @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
LoginGuard guard, MailAdmins admins) { LoginGuard guard, MailAdmins admins, com.mailserver.config.JambotronSso sso) {
this.sso = sso;
this.guard = guard; this.guard = guard;
this.admins = admins; this.admins = admins;
this.userService = userService; this.userService = userService;
@@ -73,7 +75,8 @@ public class AuthenticationController {
/** Public: is registration open, and in which domains mailboxes can be created. */ /** Public: is registration open, and in which domains mailboxes can be created. */
@GetMapping("/config") @GetMapping("/config")
public Map<String, Object> config() { public Map<String, Object> config() {
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList()); return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList(),
"ssoEnabled", sso.enabled());
} }
@PostMapping("/login") @PostMapping("/login")
@@ -99,9 +102,23 @@ public class AuthenticationController {
return delay.isZero() ? answer : answer.delayElement(delay); return delay.isZero() ? answer : answer.delayElement(delay);
} }
guard.success(ip, email); guard.success(ip, email);
if (user.isShared()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
"This is a shared mailbox: log in with your own mailbox and open it there")));
}
if (!user.isActive()) { if (!user.isActive()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated"))); return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
} }
// With «Sign in with Jambotron» the web password is only for the built-in administrator
// (Jambotron down); the others' passwords are for mail programs (SMTP / IMAP)
if (sso.enabled() && !admins.isBuiltIn(user.getEmail())) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
"Sign in with Jambotron — the password is only for mail programs")));
}
if (user.isSystem()) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
"A system mailbox has no web login: its owners open it from their mailbox")));
}
return Mono.just(ResponseEntity.ok(token(user))); return Mono.just(ResponseEntity.ok(token(user)));
}); });
}); });
@@ -138,7 +155,7 @@ public class AuthenticationController {
@PutMapping("/me") @PutMapping("/me")
public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) { public Mono<ProfileResponse> updateMe(Mono<Authentication> auth, @Valid @RequestBody Mono<ProfileUpdateRequest> body) {
return user(auth).zipWith(body) return own(auth).zipWith(body)
.flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName())) .flatMap(pair -> userService.updateDisplayName(pair.getT1(), pair.getT2().displayName()))
.flatMap(this::profile); .flatMap(this::profile);
} }
@@ -147,7 +164,7 @@ public class AuthenticationController {
public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body, public Mono<Map<String, String>> changePassword(Mono<Authentication> auth, @Valid @RequestBody Mono<PasswordChangeRequest> body,
ServerHttpRequest http) { ServerHttpRequest http) {
String ip = clientIp(http); String ip = clientIp(http);
return user(auth).zipWith(body) return own(auth).zipWith(body)
.flatMap(pair -> { .flatMap(pair -> {
User user = pair.getT1(); User user = pair.getT1();
// Someone with a stolen session must not guess the current password either // Someone with a stolen session must not guess the current password either
@@ -186,6 +203,15 @@ public class AuthenticationController {
admins.isAdmin(user.getEmail()))); admins.isAdmin(user.getEmail())));
} }
/** The session's own mailbox — not a shared / attached / administrator-opened one: no profile changes there. */
private Mono<User> own(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> com.mailserver.security.Actor.opened(a)
? Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN,
"This mailbox is opened from " + com.mailserver.security.Actor.login(a) + " — change it in Settings → MAILBOXES"))
: user(Mono.just(a)));
}
private Mono<User> user(Mono<Authentication> auth) { private Mono<User> user(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> userService.findByEmail(a.getName())) .flatMap(a -> userService.findByEmail(a.getName()))
@@ -150,7 +150,8 @@ public class MailController {
@PostMapping("/send") @PostMapping("/send")
public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) { public Mono<MessageResponse> sendMessage(Mono<Authentication> auth, @Valid @RequestBody Mono<SendRequest> requestMono) {
return auth.zipWith(requestMono) return auth.zipWith(requestMono)
.flatMap(pair -> submissionService.send(pair.getT1().getName(), pair.getT2())); .flatMap(pair -> submissionService.send(pair.getT1().getName(),
com.mailserver.security.Actor.login(pair.getT1()), pair.getT2()));
} }
// --- Drafts --- // --- Drafts ---
@@ -3,14 +3,20 @@ package com.mailserver.controller;
import com.mailserver.config.MailAdmins; import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
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.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -23,12 +29,9 @@ import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.util.Base64;
import java.util.Comparator; import java.util.Comparator;
import java.util.Locale; import java.util.List;
import java.util.regex.Pattern;
/** /**
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@, * Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
@@ -40,16 +43,24 @@ import java.util.regex.Pattern;
public class MailboxAdminController { public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class); private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?"); private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
private static final int MIN_PASSWORD = 10;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, /** @param role ADMIN or USER; @param builtIn the administrator from the configuration — the role stays */
long usedBytes, long quotaBytes, Instant createdAt) { public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt,
String role, boolean builtIn, boolean system) {
}
public record SystemRequest(boolean system) {
}
public record RoleRequest(String role) {
} }
/** @param password empty — the server generates one and returns it once */ /** @param password empty — the server generates one and returns it once */
/** @param shared shared mailbox (support@): no login of its own — members open it; password ignored */
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName, public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
@Size(max = 200) String password) { @Size(max = 200) String password, Boolean shared) {
} }
public record PasswordRequest(@Size(max = 200) String password) { public record PasswordRequest(@Size(max = 200) String password) {
@@ -58,6 +69,9 @@ public class MailboxAdminController {
public record ActiveRequest(boolean active) { public record ActiveRequest(boolean active) {
} }
public record TrapRequest(boolean trap) {
}
/** @param password the generated password, or null when the administrator chose it */ /** @param password the generated password, or null when the administrator chose it */
public record Created(MailboxView mailbox, String password) { public record Created(MailboxView mailbox, String password) {
} }
@@ -66,8 +80,17 @@ public class MailboxAdminController {
private final MailService mail; private final MailService mail;
private final MailDomains domains; private final MailDomains domains;
private final MailAdmins admins; private final MailAdmins admins;
private final JwtUtils jwt;
private final MailDirectory directory;
private final MailLists lists;
private final MailboxAccess access;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins) { public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt,
MailDirectory directory, MailLists lists, MailboxAccess access) {
this.access = access;
this.jwt = jwt;
this.directory = directory;
this.lists = lists;
this.users = users; this.users = users;
this.mail = mail; this.mail = mail;
this.domains = domains; this.domains = domains;
@@ -85,24 +108,17 @@ public class MailboxAdminController {
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) { public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return admin(auth).then(body).flatMap(request -> { return admin(auth).then(body).flatMap(request -> {
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
int at = email.lastIndexOf('@');
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
return Mono.error(bad("Not a mailbox address: " + email));
}
if (!domains.isLocal(email)) {
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
}
String chosen = request.password() == null ? "" : request.password(); String chosen = request.password() == null ? "" : request.password();
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) { if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one")); return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
} }
String password = chosen.isEmpty() ? randomPassword() : chosen; boolean shared = Boolean.TRUE.equals(request.shared());
return users.register(email, password, request.displayName()) String password = chosen.isEmpty() || shared ? randomPassword() : chosen;
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists")) return directory.createMailbox(request.email(), request.displayName(), password)
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email)) .flatMap(user -> shared ? users.setShared(user, true) : Mono.just(user))
.doOnNext(user -> log.info("{} created by administrator: {}", shared ? "Shared mailbox" : "Mailbox", user.getEmail()))
.flatMap(this::view) .flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null)); .map(view -> new Created(view, chosen.isEmpty() && !shared ? password : null));
}); });
} }
@@ -123,6 +139,21 @@ public class MailboxAdminController {
}); });
} }
/**
* Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox.
* Every opening is logged — who opened which mailbox.
*/
@PostMapping("/{id}/open")
public Mono<LoginResponse> open(Mono<Authentication> auth, @PathVariable Long id) {
return admin(auth).flatMap(me -> mailbox(id).map(user -> {
log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
LoginResponse response = new LoginResponse();
response.setToken(jwt.generateToken(user.getEmail(), me));
response.setEmail(user.getEmail());
return response;
}));
}
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */ /** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
@PutMapping("/{id}/active") @PutMapping("/{id}/active")
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) { public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
@@ -135,6 +166,113 @@ public class MailboxAdminController {
}))).flatMap(this::view); }))).flatMap(this::view);
} }
/** Deleted for good with all its mail (Settings → MAILBOXES → Delete); not your own mailbox. */
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
return admin(auth).flatMap(me -> mailbox(id).flatMap(user -> {
if (user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not delete your own mailbox"));
}
return mail.deleteMailbox(user)
.doOnSuccess(v -> log.warn("Mailbox {} deleted with all its mail by administrator {}", user.getEmail(), me));
}));
}
/** Role ADMIN (manages mailboxes, lists, roles — Admin panel) or USER. Not your own, not the built-in administrator. */
@PutMapping("/{id}/role")
public Mono<MailboxView> role(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<RoleRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (!"ADMIN".equals(request.role()) && !"USER".equals(request.role())) {
return Mono.error(bad("Role: ADMIN or USER"));
}
boolean toAdmin = "ADMIN".equals(request.role());
if (user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not change your own role"));
}
if (!toAdmin && admins.isBuiltIn(user.getEmail())) {
return Mono.error(bad("The built-in administrator stays ADMIN"));
}
if (toAdmin && (user.isShared() || user.isSystem())) {
return Mono.error(bad("A shared mailbox has no login of its own — it can not be ADMIN"));
}
return users.setAdmin(user, toAdmin).doOnNext(u -> {
admins.grant(u.getEmail(), toAdmin);
log.warn("Mailbox {} role {} given by administrator {}", u.getEmail(), request.role(), me);
});
}))).flatMap(this::view);
}
/**
* System mailbox (no-reply@, monitor@, staging@): no web login, not in USERS; the built-in administrators
* open it from their mailbox; programs keep its password. Off — an ordinary mailbox again.
*/
@PutMapping("/{id}/system")
public Mono<MailboxView> system(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<SystemRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (request.system() && (user.isShared() || admins.isAdmin(user.getEmail()))) {
return Mono.error(bad("A shared mailbox or an ADMIN can not be a system mailbox"));
}
log.info("Mailbox {} system {} by administrator {}", user.getEmail(), request.system() ? "ON" : "OFF", me);
return users.setSystem(user, request.system())
.flatMap(u -> !request.system() ? Mono.just(u)
: Flux.fromIterable(admins.builtIn()).concatMap(users::findOptional)
.concatMap(owner -> access.grant(u.getId(), owner.getId(), MailboxAccess.MEMBER))
.then(Mono.just(u)));
}))).flatMap(this::view);
}
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
log.info("Mailbox {} trap {} by administrator {}", user.getEmail(), request.trap() ? "ON" : "OFF", me);
return users.setTrap(user, request.trap());
}))).flatMap(this::view);
}
public record MembersRequest(List<String> members) {
}
/** Exactly these members may open the mailbox (shared mailbox; for an ordinary one — delegates). */
@PutMapping("/{id}/members")
public Mono<MailboxView> members(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<MembersRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user ->
directory.setMembers(user, request.members())
.doOnSuccess(v -> log.info("Members of {} set by administrator {}: {}", user.getEmail(), me, request.members()))
.then(users.byId(id)))))
.flatMap(this::view);
}
// --- Mailing lists (Settings → LISTS) ---
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
List<String> members) {
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(Mono<Authentication> auth) {
return admin(auth).thenMany(lists.all());
}
/** Creates or replaces the list {@code address} (settings + the exact member set). */
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(Mono<Authentication> auth, @PathVariable String address,
@RequestBody Mono<ListRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request ->
directory.saveList(address, request.folder(), request.description(), request.openToOutside(), request.members())
.doOnNext(list -> log.info("List {} saved by administrator {}: {} members", list.address(), me, list.members().size()))));
}
@DeleteMapping("/lists/{address}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> deleteList(Mono<Authentication> auth, @PathVariable String address) {
return admin(auth).flatMap(me -> lists.delete(address)
.flatMap(deleted -> deleted ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found")))
.doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me)));
}
private Mono<String> admin(Mono<Authentication> auth) { private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName) .map(Authentication::getName)
@@ -147,10 +285,10 @@ public class MailboxAdminController {
} }
private Mono<MailboxView> view(User user) { private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L) return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), .map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt())); user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail()), user.isSystem()));
} }
private static ResponseStatusException bad(String message) { private static ResponseStatusException bad(String message) {
@@ -158,8 +296,6 @@ public class MailboxAdminController {
} }
static String randomPassword() { static String randomPassword() {
byte[] bytes = new byte[18]; return MailDirectory.randomPassword();
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
} }
} }
@@ -0,0 +1,130 @@
package com.mailserver.controller;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.security.Actor;
import com.mailserver.security.LoginGuard;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Locale;
/**
* The mailboxes of the logged-in user (left menu): own, shared ones it is a member of, attached ones.
* Always works for the LOGIN behind the session, also when a shared mailbox is open.
*/
@RestController
@RequestMapping("/api/mailboxes")
public class MailboxesController {
private static final Logger log = LoggerFactory.getLogger(MailboxesController.class);
public record AttachRequest(@NotBlank @Size(max = 254) String email, @NotBlank @Size(max = 200) String password) {
}
private final MailboxAccess access;
private final UserService users;
private final JwtUtils jwt;
private final LoginGuard guard;
public MailboxesController(MailboxAccess access, UserService users, JwtUtils jwt, LoginGuard guard) {
this.access = access;
this.users = users;
this.jwt = jwt;
this.guard = guard;
}
@GetMapping
public Flux<MailboxAccess.Entry> mine(Mono<Authentication> auth) {
return login(auth).flatMapMany(access::mailboxes);
}
/** A session of this mailbox for the login (switch in the left menu). */
@PostMapping("/{email}/session")
public Mono<LoginResponse> session(Mono<Authentication> auth, @PathVariable String email) {
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.has(target.getId(), me.getId())
.flatMap(allowed -> {
if (!allowed) {
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "No access to this mailbox"));
}
LoginResponse response = new LoginResponse();
boolean own = target.getId().equals(me.getId());
response.setToken(own ? jwt.generateToken(me.getEmail()) : jwt.generateToken(target.getEmail(), me.getEmail()));
response.setEmail(target.getEmail());
return Mono.just(response);
})));
}
/** Adds another mailbox the user knows the password of. The password is checked like a login (LoginGuard). */
@PostMapping("/attach")
@ResponseStatus(HttpStatus.CREATED)
public Mono<MailboxAccess.Entry> attach(Mono<Authentication> auth, @RequestBody Mono<AttachRequest> body, ServerHttpRequest http) {
String ip = AuthenticationController.clientIp(http);
return login(auth).zipWith(body).flatMap(pair -> {
User me = pair.getT1();
String email = pair.getT2().email().trim().toLowerCase(Locale.ROOT);
Duration wait = guard.retryAfter(ip, email);
if (!wait.isZero()) {
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Too many failed attempts, try again in " + LoginGuard.human(wait)));
}
return users.findOptional(email)
.filter(target -> target.isActive() && !target.isShared() && users.checkPassword(target, pair.getT2().password()))
.switchIfEmpty(Mono.defer(() -> {
guard.failure(ip, email);
log.warn("WEB {}: {} failed to attach {}", ip, me.getEmail(), email);
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wrong address or password"));
}))
.flatMap(target -> {
if (target.getId().equals(me.getId())) {
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, "This is your own mailbox"));
}
guard.success(ip, email);
log.info("{} attached mailbox {}", me.getEmail(), email);
return access.grant(target.getId(), me.getId(), MailboxAccess.ATTACHED)
.then(access.unread(target.getId()))
.map(n -> new MailboxAccess.Entry(target.getEmail(), target.getDisplayName(), MailboxAccess.ATTACHED, false, n));
});
});
}
/** Removes the mailbox from the user's menu (attached or shared). */
@DeleteMapping("/{email}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> leave(Mono<Authentication> auth, @PathVariable String email) {
return login(auth).flatMap(me -> mailbox(email).flatMap(target -> access.revoke(target.getId(), me.getId())
.flatMap(done -> done ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Not in your mailboxes")))));
}
private Mono<User> login(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.flatMap(a -> users.findOptional(Actor.login(a)))
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)));
}
private Mono<User> mailbox(String email) {
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")));
}
}
@@ -0,0 +1,193 @@
package com.mailserver.controller;
import com.mailserver.domain.User;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService;
import jakarta.validation.constraints.Size;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
/**
* Service API for Jambotron (staff mailboxes): token {@code SERVICE_TOKEN} in {@code Authorization: Bearer …}.
* Without the token configured the API is off (404). Accounts: create (password returned ONCE), switch on/off,
* new password; lists: the exact settings and members, Jambotron is the source of truth for the lists it keeps.
*/
@RestController
@RequestMapping("/api/service")
public class ServiceController {
private static final Logger log = LoggerFactory.getLogger(ServiceController.class);
/** @param displayName null — unchanged (new account: the name before @) */
/** @param admin mail administrator (Jambotron ADMIN); null — leave as it is */
public record AccountRequest(@Size(max = 100) String displayName, Boolean active, Boolean admin) {
}
public record AccountView(String email, String displayName, boolean active, boolean admin, List<String> lists) {
}
/** @param password generated, only when the account was just created or got a new password */
public record AccountResult(AccountView account, String password) {
}
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
List<String> members) {
}
private final byte[] token;
private final UserService users;
private final MailLists lists;
private final MailDirectory directory;
private final MailboxAccess access;
private final com.mailserver.config.MailAdmins admins;
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
MailDirectory directory, MailboxAccess access, com.mailserver.config.MailAdmins admins) {
this.access = access;
this.admins = admins;
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
this.users = users;
this.lists = lists;
this.directory = directory;
}
@GetMapping("/accounts/{email}")
public Mono<AccountView> account(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email) {
check(auth);
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
.flatMap(this::view);
}
/** Creates the account (201 + password once) or updates name / on-off (200). Switched off → out of every list. */
@PutMapping("/accounts/{email}")
public Mono<ResponseEntity<AccountResult>> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email, @RequestBody Mono<AccountRequest> body) {
check(auth);
String address = directory.address(email);
return body.defaultIfEmpty(new AccountRequest(null, null, null)).flatMap(request -> users.findOptional(address)
.flatMap(user -> update(user, request).flatMap(this::view)
.map(view -> ResponseEntity.ok(new AccountResult(view, null))))
.switchIfEmpty(Mono.defer(() -> {
String password = MailDirectory.randomPassword();
return directory.createMailbox(address, request.displayName(), password)
.flatMap(user -> Boolean.FALSE.equals(request.active()) || Boolean.TRUE.equals(request.admin())
? update(user, request) : Mono.just(user))
.doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail()))
.flatMap(this::view)
.map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password)));
})));
}
/** New generated password, returned once (Jambotron profile → «new mail password»). */
@PostMapping("/accounts/{email}/password")
public Mono<AccountResult> password(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email) {
check(auth);
String password = MailDirectory.randomPassword();
return users.findOptional(email)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox")))
.flatMap(user -> users.setPassword(user, password))
.doOnNext(user -> log.info("Password of {} set by Jambotron", user.getEmail()))
.flatMap(this::view)
.map(view -> new AccountResult(view, password));
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(@RequestHeader(value = "Authorization", required = false) String auth) {
check(auth);
return lists.all();
}
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<ListRequest> body) {
check(auth);
return body.flatMap(request -> directory.saveList(address, request.folder(), request.description(),
request.openToOutside(), request.members()))
.doOnNext(list -> log.info("List {} saved by Jambotron: {}", list.address(), list.members()));
}
@DeleteMapping("/lists/{address}")
public Mono<ResponseEntity<Void>> deleteList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address) {
check(auth);
return lists.delete(address).map(deleted -> deleted
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build());
}
public record SharedRequest(@Size(max = 100) String displayName, List<String> members) {
}
public record SharedView(String email, String displayName, List<String> members) {
}
/** Shared mailbox (support@, billing@): created when missing, exactly these members. */
@PutMapping("/shared/{address}")
public Mono<SharedView> saveShared(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<SharedRequest> body) {
check(auth);
return body.flatMap(request -> directory.saveShared(address, request.displayName(), request.members()))
.flatMap(user -> access.members(user.getId()).collectList()
.map(members -> new SharedView(user.getEmail(), user.getDisplayName(), members)))
.doOnNext(view -> log.info("Shared mailbox {} saved by Jambotron: {}", view.email(), view.members()));
}
private Mono<User> update(User user, AccountRequest request) {
Mono<User> renamed = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
: users.updateDisplayName(user, request.displayName());
Mono<User> named = request.admin() == null || request.admin() == user.isAdmin() ? renamed
: renamed.flatMap(u -> users.setAdmin(u, request.admin()))
.doOnNext(u -> {
admins.grant(u.getEmail(), u.isAdmin());
log.info("Mailbox {} {} mail administrator by Jambotron", u.getEmail(), u.isAdmin() ? "made" : "is no longer");
});
if (request.active() == null || request.active() == user.isActive()) {
return named;
}
boolean on = request.active();
return named.flatMap(u -> users.setActive(u, on))
.flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId())
.then(access.removeEverywhere(u.getId())).thenReturn(u))
.doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off"));
}
private Mono<AccountView> view(User user) {
return lists.listsOf(user.getId()).collectList()
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), admins.isAdmin(user.getEmail()), in));
}
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
private void check(String auth) {
if (token == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
byte[] given = auth != null && auth.startsWith("Bearer ")
? auth.substring(7).trim().getBytes(StandardCharsets.UTF_8) : new byte[0];
if (!MessageDigest.isEqual(token, given)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
}
}
@@ -0,0 +1,176 @@
package com.mailserver.controller;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.mailserver.config.JambotronSso;
import com.mailserver.config.MailAdmins;
import com.mailserver.domain.User;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* «Sign in with Jambotron» — OAuth 2.0 authorization code + PKCE (S256):
* <ol>
* <li>{@code /api/oauth/start} → browser to {@code JAMBOTRON_URL/oauth/authorize?client_id=mail&…}</li>
* <li>Jambotron logs the person in (Google / GitHub / password), only staff get a one-time code</li>
* <li>{@code /api/oauth/callback?code&state} → code exchanged server to server at {@code /api/oauth/token}
* (Bearer SERVICE_TOKEN + code_verifier) → who it is, roles, their work mailbox</li>
* <li>session of that mailbox → {@code /sso#token=…}; ADMIN of Jambotron gets role ADMIN here</li>
* </ol>
*/
@RestController
@RequestMapping("/api/oauth")
public class SsoController {
private static final Logger log = LoggerFactory.getLogger(SsoController.class);
private static final Duration STATE_TTL = Duration.ofMinutes(10);
private static final SecureRandom RANDOM = new SecureRandom();
/** What Jambotron says about the person (its /api/oauth/token). */
@JsonIgnoreProperties(ignoreUnknown = true)
public record Identity(String sub, String username, String email, List<String> roles, String mailbox) {
}
private record Pending(String verifier, Instant until) {
}
private final Map<String, Pending> pending = new ConcurrentHashMap<>();
private final JambotronSso sso;
private final UserService users;
private final MailAdmins admins;
private final JwtUtils jwt;
private final WebClient http;
public SsoController(JambotronSso sso, UserService users, MailAdmins admins, JwtUtils jwt, WebClient.Builder http) {
this.sso = sso;
this.users = users;
this.admins = admins;
this.jwt = jwt;
this.http = http.build();
}
@GetMapping("/start")
public ResponseEntity<Void> start() {
if (!sso.enabled()) {
return back("/login?sso_error=" + enc("Sign in with Jambotron is not set up on this server"));
}
Instant now = Instant.now();
pending.values().removeIf(p -> p.until().isBefore(now));
String state = random(24);
String verifier = random(48);
pending.put(state, new Pending(verifier, now.plus(STATE_TTL)));
return back(sso.url() + "/oauth/authorize?response_type=code&client_id=mail"
+ "&redirect_uri=" + enc(sso.callback()) + "&state=" + state
+ "&code_challenge=" + challenge(verifier) + "&code_challenge_method=S256");
}
@GetMapping("/callback")
public Mono<ResponseEntity<Void>> callback(@RequestParam(required = false) String code,
@RequestParam(required = false) String state,
@RequestParam(required = false) String error) {
Pending p = state == null ? null : pending.remove(state);
if (error != null) {
return Mono.just(fail(error));
}
if (code == null || p == null || p.until().isBefore(Instant.now())) {
return Mono.just(fail("The sign-in took too long or was not started here — try again"));
}
return http.post().uri(sso.url() + "/api/oauth/token")
.header("Authorization", "Bearer " + sso.token())
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("grant_type", "authorization_code", "client_id", "mail", "code", code,
"code_verifier", p.verifier(), "redirect_uri", sso.callback()))
.retrieve()
.bodyToMono(Identity.class)
.timeout(Duration.ofSeconds(15))
.flatMap(this::session)
.onErrorResume(WebClientResponseException.class, e -> {
log.warn("Sign in with Jambotron: token exchange refused {} {}", e.getStatusCode(), e.getResponseBodyAsString());
return Mono.just(fail("Jambotron did not confirm the sign-in (" + e.getStatusCode().value() + ")"));
})
.onErrorResume(e -> !(e instanceof SsoRefused), e -> {
log.warn("Sign in with Jambotron failed: {}", e.toString());
return Mono.just(fail("Jambotron is not reachable — try again later"));
})
.onErrorResume(SsoRefused.class, e -> Mono.just(fail(e.getMessage())));
}
private Mono<ResponseEntity<Void>> session(Identity who) {
if (who.mailbox() == null || who.mailbox().isBlank()) {
return Mono.error(new SsoRefused("You have no work mailbox: mail is for Jambotron staff"));
}
boolean admin = who.roles() != null && who.roles().contains("ROLE_ADMIN");
return users.findOptional(who.mailbox())
.switchIfEmpty(Mono.error(new SsoRefused("Your mailbox " + who.mailbox() + " is not created yet — it appears within 10 minutes")))
.flatMap(user -> {
if (!user.isActive() || user.isShared() || user.isSystem()) {
return Mono.error(new SsoRefused("Mailbox " + who.mailbox() + " is switched off"));
}
return admin && !user.isAdmin() ? users.setAdmin(user, true) : Mono.just(user);
})
.map(user -> {
if (user.isAdmin()) {
admins.grant(user.getEmail(), true);
}
log.info("WEB: {} signed in with Jambotron as {} ({})", user.getEmail(), who.username(), who.email());
return back("/sso#token=" + enc(jwt.generateToken(user.getEmail())) + "&email=" + enc(user.getEmail()));
});
}
private static final class SsoRefused extends RuntimeException {
SsoRefused(String message) {
super(message);
}
}
private static ResponseEntity<Void> fail(String message) {
return back("/login?sso_error=" + enc(message));
}
private static ResponseEntity<Void> back(String location) {
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(location)).build();
}
private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private static String random(int bytes) {
byte[] b = new byte[bytes];
RANDOM.nextBytes(b);
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
}
static String challenge(String verifier) {
try {
byte[] hash = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -54,6 +54,13 @@ public class Message {
@Column("message_id") @Column("message_id")
private String messageId; private String messageId;
/** Who answered it from the web client (V7) — the login, also for a shared mailbox. */
@Column("answered_by")
private String answeredBy;
public String getAnsweredBy() { return answeredBy; }
public void setAnsweredBy(String answeredBy) { this.answeredBy = answeredBy; }
public Long getId() { return id; } public Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
@@ -30,6 +30,22 @@ public class User {
@Column("is_active") @Column("is_active")
private boolean isActive = true; private boolean isActive = true;
/** Trap: what this mailbox sends is stored in its own INBOX, never delivered (V5). */
@Column("is_trap")
private boolean isTrap = false;
/** Mail administrator given by Jambotron through the service API (V8); plus mailserver.admins. */
@Column("is_admin")
private boolean isAdmin = false;
/** System mailbox (no-reply@ …): no web login, owners open it, programs use its passwords (V9). */
@Column("is_system")
private boolean isSystem = false;
/** Shared mailbox (V7): no login of its own, members open it. */
@Column("is_shared")
private boolean isShared = false;
@Column("created_at") @Column("created_at")
private Instant createdAt; private Instant createdAt;
@@ -54,6 +70,16 @@ public class User {
public boolean isActive() { return isActive; } public boolean isActive() { return isActive; }
public void setActive(boolean active) { isActive = active; } public void setActive(boolean active) { isActive = active; }
public boolean isTrap() { return isTrap; }
public void setTrap(boolean trap) { isTrap = trap; }
public boolean isAdmin() { return isAdmin; }
public void setAdmin(boolean admin) { isAdmin = admin; }
public boolean isSystem() { return isSystem; }
public void setSystem(boolean system) { isSystem = system; }
public boolean isShared() { return isShared; }
public void setShared(boolean shared) { isShared = shared; }
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; }
} }
@@ -20,6 +20,11 @@ public class MessageResponse {
private Long folderId; private Long folderId;
private boolean hasAttachments; private boolean hasAttachments;
private int attachmentCount; private int attachmentCount;
/** login that answered it from the web client, or null */
private String answeredBy;
public String getAnsweredBy() { return answeredBy; }
public void setAnsweredBy(String answeredBy) { this.answeredBy = answeredBy; }
public Long getId() { return id; } public Long getId() { return id; }
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
@@ -26,6 +26,12 @@ public class SendRequest {
private Long draftId; private Long draftId;
/** The message this one answers (web client «Reply»): marked answered, by whom. */
private Long replyToId;
public Long getReplyToId() { return replyToId; }
public void setReplyToId(Long replyToId) { this.replyToId = replyToId; }
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; }
@@ -8,6 +8,7 @@ import com.mailserver.repository.MessageRepository;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.MessageSources; import com.mailserver.service.MessageSources;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import jakarta.mail.Session; import jakarta.mail.Session;
@@ -45,10 +46,12 @@ public class DbImapStore implements ImapStore {
private final AttachmentRepository attachments; private final AttachmentRepository attachments;
private final MessageSources sources; private final MessageSources sources;
private final String hostname; private final String hostname;
private final MailboxAccess access;
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService, public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources, MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
String hostname) { String hostname, MailboxAccess access) {
this.access = access;
this.db = db; this.db = db;
this.users = users; this.users = users;
this.userService = userService; this.userService = userService;
@@ -61,11 +64,9 @@ public class DbImapStore implements ImapStore {
@Override @Override
public Account authenticate(String username, String password) { public Account authenticate(String username, String password) {
User user = users.findByEmail(username.trim().toLowerCase(Locale.ROOT)).block(); // login or mailbox*login (shared / attached mailbox in a mail program)
if (user == null || !user.isActive() || !userService.checkPassword(user, password)) { User user = access.login(username, password);
return null; return user == null ? null : new Account(user.getId(), user.getEmail());
}
return new Account(user.getId(), user.getEmail());
} }
@Override @Override
@@ -12,6 +12,10 @@ public interface AttachmentRepository extends R2dbcRepository<Attachment, Long>
Mono<Long> countByMessageId(Long messageId); Mono<Long> countByMessageId(Long messageId);
/** All attachments of a mailbox — their files are removed with the mailbox. */
@Query("SELECT a.* 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}")
Flux<Attachment> findByMailbox(Long userId);
@Modifying @Modifying
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}") @Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
Mono<Integer> moveAll(Long from, Long to); Mono<Integer> moveAll(Long from, Long to);
@@ -0,0 +1,23 @@
package com.mailserver.security;
import org.springframework.security.core.Authentication;
/**
* Who is acting: the mailbox the session belongs to ({@link Authentication#getName()}) and the login behind it —
* the same for an own session, another one when a member opened a shared mailbox or an administrator opened one.
*/
public final class Actor {
private Actor() {
}
/** The login behind the session. */
public static String login(Authentication auth) {
return auth.getDetails() instanceof String by && !by.isBlank() ? by : auth.getName();
}
/** The session is another login's view of this mailbox: no password / profile changes from it. */
public static boolean opened(Authentication auth) {
return !login(auth).equalsIgnoreCase(auth.getName());
}
}
@@ -43,6 +43,8 @@ public class JwtAuthenticationFilter implements WebFilter {
UserDetails userDetails = new User(email, "", Collections.emptyList()); UserDetails userDetails = new User(email, "", Collections.emptyList());
UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList()); new UsernamePasswordAuthenticationToken(userDetails, null, Collections.emptyList());
// the login that opened this mailbox (shared / attached / administrator's OPEN); see Actor
authentication.setDetails(jwtUtils.getByFromToken(token));
return chain.filter(exchange) return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication)); .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
@@ -0,0 +1,60 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/** App passwords (V9): one per mail program, SMTP / IMAP only — never the web client. */
@Service
public class AppPasswords {
public record AppPassword(Long id, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {
}
private final DatabaseClient db;
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
public AppPasswords(DatabaseClient db) {
this.db = db;
}
public Flux<AppPassword> list(Long userId) {
return db.sql("SELECT id, name, created_at, last_used_at FROM app_passwords WHERE user_id = :u ORDER BY id")
.bind("u", userId)
.map(row -> new AppPassword(row.get("id", Long.class), row.get("name", String.class),
row.get("created_at", LocalDateTime.class), row.get("last_used_at", LocalDateTime.class)))
.all();
}
/** @return the password itself — shown once */
public Mono<String> create(Long userId, String name) {
String password = MailDirectory.randomPassword();
return db.sql("INSERT INTO app_passwords (user_id, name, hash) VALUES (:u, :n, :h)")
.bind("u", userId).bind("n", name).bind("h", encoder.encode(password))
.then().thenReturn(password);
}
public Mono<Boolean> delete(Long userId, Long id) {
return db.sql("DELETE FROM app_passwords WHERE id = :id AND user_id = :u").bind("id", id).bind("u", userId)
.fetch().rowsUpdated().map(n -> n > 0);
}
/** SMTP / IMAP: does the password match one of the app passwords of this login (and note the use). */
public boolean matches(Long userId, String password) {
Long hit = db.sql("SELECT id, hash FROM app_passwords WHERE user_id = :u").bind("u", userId)
.map(row -> new Object[]{row.get("id", Long.class), row.get("hash", String.class)})
.all()
.filter(r -> encoder.matches(password, (String) r[1]))
.map(r -> (Long) r[0])
.next().block();
if (hit == null) {
return false;
}
db.sql("UPDATE app_passwords SET last_used_at = NOW() WHERE id = :id").bind("id", hit).then().subscribe();
return true;
}
}
@@ -54,6 +54,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
/** Blocking: call from a worker thread. */ /** Blocking: call from a worker thread. */
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted) public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
throws MessagingException, IOException { throws MessagingException, IOException {
deliver(raw, mailbox, null, remoteIp, envelopeFrom, trusted);
}
/** @param folder own folder of the mailbox ("+tag", mailing list), created when missing; null — INBOX */
public void deliver(byte[] raw, String mailbox, String folder, String remoteIp, String envelopeFrom, boolean trusted)
throws MessagingException, IOException {
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw)); MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
String senderEmail = envelopeFrom; String senderEmail = envelopeFrom;
@@ -80,7 +86,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
com.mailserver.domain.Message stored = com.mailserver.domain.Message stored =
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam, mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
addresses(mime, jakarta.mail.Message.RecipientType.TO), addresses(mime, jakarta.mail.Message.RecipientType.TO),
addresses(mime, jakarta.mail.Message.RecipientType.CC)).block(); addresses(mime, jakarta.mail.Message.RecipientType.CC), folder).block();
if (stored != null) { if (stored != null) {
for (FilePart file : attachments(mime)) { for (FilePart file : attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
@@ -0,0 +1,132 @@
package com.mailserver.service;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* Mailboxes and mailing lists share one address space: one rule for both, used by Settings (administrators)
* and by the service API (Jambotron). Names never contain "+" — the part after it is a folder label.
*/
@Service
public class MailDirectory {
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?");
private static final Pattern FOLDER = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
public static final int MIN_PASSWORD = 8;
private final UserService users;
private final MailLists lists;
private final MailDomains domains;
private final MailboxAccess access;
public MailDirectory(UserService users, MailLists lists, MailDomains domains, MailboxAccess access) {
this.access = access;
this.users = users;
this.lists = lists;
this.domains = domains;
}
/** Lower-case address of our domain with an allowed name, or 400. */
public String address(String raw) {
String email = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT);
int at = email.lastIndexOf('@');
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
throw bad("Not a mailbox address: " + email + " (letters, digits, . _ - ; no +)");
}
if (!domains.isLocal(email)) {
throw bad("Only in " + String.join(", ", domains.domains().stream().sorted().toList()));
}
return email;
}
/** New mailbox; the address must not be taken by a mailbox or a list. 409 when taken. */
public Mono<User> createMailbox(String rawEmail, String displayName, String password) {
String email = address(rawEmail);
if (password == null || password.length() < MIN_PASSWORD) {
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters"));
}
return lists.exists(email).flatMap(isList -> isList
? Mono.<User>error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailing list"))
: users.register(email, password, displayName)
.onErrorMap(IllegalArgumentException.class,
e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists")));
}
/**
* Creates or replaces a list. Members: mailboxes of this server (addresses); unknown ones → 400 naming them.
*/
public Mono<MailLists.MailList> saveList(String rawAddress, String rawFolder, String description,
boolean openToOutside, List<String> members) {
String address = address(rawAddress);
String folder = rawFolder == null || rawFolder.isBlank() ? null : rawFolder.trim();
if (folder != null && (!FOLDER.matcher(folder).matches()
|| MailService.SYSTEM_FOLDERS.stream().anyMatch(folder::equalsIgnoreCase))) {
return Mono.error(bad("Folder: letters, digits, . _ - (up to 50), not a system folder; empty — INBOX"));
}
String about = description == null || description.isBlank() ? null : description.trim();
if (about != null && about.length() > 255) {
return Mono.error(bad("Description: up to 255 characters"));
}
List<String> wanted = members == null ? List.of()
: members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList();
return users.findOptional(address).hasElement().flatMap(isMailbox -> {
if (isMailbox) {
return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailbox"));
}
List<String> unknown = new ArrayList<>();
return Flux.fromIterable(wanted)
.concatMap(member -> users.findOptional(member)
.map(User::getId)
.switchIfEmpty(Mono.fromRunnable(() -> unknown.add(member))))
.collectList()
.flatMap(ids -> unknown.isEmpty()
? lists.save(address, folder, about, openToOutside, ids)
: Mono.error(bad("No such mailbox: " + String.join(", ", unknown))));
});
}
/** Exactly these members (mailboxes of this server) for the mailbox; unknown ones → 400 naming them. */
public Mono<Void> setMembers(User mailbox, List<String> members) {
List<String> wanted = members == null ? List.of()
: members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList();
List<String> unknown = new ArrayList<>();
return Flux.fromIterable(wanted)
.concatMap(member -> users.findOptional(member).map(User::getId)
.switchIfEmpty(Mono.fromRunnable(() -> unknown.add(member))))
.collectList()
.flatMap(ids -> unknown.isEmpty() ? access.setMembers(mailbox.getId(), ids)
: Mono.error(bad("No such mailbox: " + String.join(", ", unknown))));
}
/** Shared mailbox: created when missing (no login of its own), then exactly these members. */
public Mono<User> saveShared(String rawAddress, String displayName, List<String> members) {
String address = address(rawAddress);
return users.findOptional(address)
.switchIfEmpty(Mono.defer(() -> createMailbox(address, displayName, randomPassword())))
.flatMap(user -> user.isShared() ? Mono.just(user) : users.setShared(user, true))
.flatMap(user -> displayName == null || displayName.isBlank() ? Mono.just(user) : users.updateDisplayName(user, displayName))
.flatMap(user -> setMembers(user, members).thenReturn(user));
}
public static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
}
@@ -0,0 +1,101 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
/**
* Mailing lists (V6): {@code alerts-prod@} → every member gets their own copy in the list's folder.
* Kept by the administrators (Settings → LISTS) and by Jambotron through the service API.
*/
@Service
public class MailLists {
/** @param folder where members get the copy; null — INBOX */
public record MailList(Long id, String address, String folder, String description, boolean openToOutside,
List<String> members) {
}
private final DatabaseClient db;
public MailLists(DatabaseClient db) {
this.db = db;
}
public Mono<MailList> find(String address) {
return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists WHERE address = :a")
.bind("a", address.toLowerCase(Locale.ROOT))
.map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class),
row.get("folder", String.class), row.get("description", String.class),
Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of()))
.one()
.flatMap(this::withMembers);
}
public Mono<Boolean> exists(String address) {
return db.sql("SELECT 1 FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT))
.map((row, meta) -> true).one().defaultIfEmpty(false);
}
public Flux<MailList> all() {
return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists ORDER BY address")
.map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class),
row.get("folder", String.class), row.get("description", String.class),
Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of()))
.all()
.concatMap(this::withMembers);
}
/** Lists this mailbox is a member of (shown to the mailbox owner). */
public Flux<String> listsOf(Long userId) {
return db.sql("SELECT l.address FROM mail_lists l JOIN mail_list_members m ON m.list_id = l.id "
+ "WHERE m.user_id = :u ORDER BY l.address")
.bind("u", userId).map((row, meta) -> row.get("address", String.class)).all();
}
/**
* Creates or replaces the list: settings and the exact member set. Members are mailbox ids (checked by the caller).
*/
@Transactional
public Mono<MailList> save(String address, String folder, String description, boolean openToOutside,
Collection<Long> memberIds) {
String a = address.toLowerCase(Locale.ROOT);
var insert = db.sql("INSERT INTO mail_lists (address, folder, description, open_to_outside) "
+ "VALUES (:a, :f, :d, :o) ON CONFLICT (address) DO UPDATE "
+ "SET folder = EXCLUDED.folder, description = EXCLUDED.description, "
+ "open_to_outside = EXCLUDED.open_to_outside RETURNING id")
.bind("a", a).bind("o", openToOutside);
insert = folder == null ? insert.bindNull("f", String.class) : insert.bind("f", folder);
insert = description == null ? insert.bindNull("d", String.class) : insert.bind("d", description);
return insert.map((row, meta) -> row.get("id", Long.class)).one()
.flatMap(id -> db.sql("DELETE FROM mail_list_members WHERE list_id = :l").bind("l", id).then()
.thenMany(Flux.fromIterable(memberIds).distinct()
.concatMap(member -> db.sql("INSERT INTO mail_list_members (list_id, user_id) VALUES (:l, :u)")
.bind("l", id).bind("u", member).then()))
.then(find(a)));
}
public Mono<Boolean> delete(String address) {
return db.sql("DELETE FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT))
.fetch().rowsUpdated().map(n -> n > 0);
}
/** Takes the mailbox out of every list (mailbox switched off / role removed in Jambotron). */
public Mono<Long> removeEverywhere(Long userId) {
return db.sql("DELETE FROM mail_list_members WHERE user_id = :u").bind("u", userId).fetch().rowsUpdated();
}
private Mono<MailList> withMembers(MailList list) {
return db.sql("SELECT u.email FROM mail_list_members m JOIN users u ON u.id = m.user_id "
+ "WHERE m.list_id = :l ORDER BY u.email")
.bind("l", list.id()).map((row, meta) -> row.get("email", String.class)).all().collectList()
.map(members -> new MailList(list.id(), list.address(), list.folder(), list.description(),
list.openToOutside(), members));
}
}
@@ -162,6 +162,34 @@ public class MailService {
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted"))); .switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted")));
} }
private static final java.util.regex.Pattern DELIVERY_FOLDER = java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}");
/**
* A "+tag" / list folder that may be used as a folder name, or null (→ INBOX). A sender never puts mail into
* a system folder (Spam, Trash, Sent …) this way.
*/
static String deliveryFolderName(String raw) {
if (raw == null || !DELIVERY_FOLDER.matcher(raw).matches() || SYSTEM_FOLDERS.stream().anyMatch(raw::equalsIgnoreCase)) {
return null;
}
return raw;
}
/** The folder with this name (any case), created as an own folder when missing; two at once — one wins. */
private Mono<Folder> deliveryFolder(Long userId, String name) {
Mono<Folder> existing = folderRepository.findByUserId(userId)
.filter(folder -> folder.getName().equalsIgnoreCase(name))
.next();
return existing.switchIfEmpty(Mono.defer(() -> {
Folder folder = new Folder();
folder.setUserId(userId);
folder.setName(name);
folder.setCreatedAt(Instant.now());
return folderRepository.save(folder)
.onErrorResume(org.springframework.dao.DataIntegrityViolationException.class, e -> existing);
}));
}
private Mono<Folder> systemFolder(Long userId, String name) { private Mono<Folder> systemFolder(Long userId, String name) {
return folderRepository.findByUserIdAndName(userId, name) return folderRepository.findByUserIdAndName(userId, name)
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found"))); .switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
@@ -253,12 +281,25 @@ public class MailService {
public Mono<Message> 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,
String toHeader, String ccHeader) { String toHeader, String ccHeader) {
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag,
toHeader, ccHeader, null);
}
/**
* @param folder own folder for the message ("+tag" of the address, folder of a mailing list), created when
* missing; null or not a valid folder name — INBOX. Spam still goes to Spam.
*/
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml, boolean externalSpamFlag,
String toHeader, String ccHeader, String targetFolder) {
return userRepository.findByEmail(recipientEmail) return userRepository.findByEmail(recipientEmail)
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail) .flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
.flatMap(blacklisted -> { .flatMap(blacklisted -> {
boolean isSpam = blacklisted || externalSpamFlag; boolean isSpam = blacklisted || externalSpamFlag;
return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX") String own = isSpam ? null : deliveryFolderName(targetFolder);
return (own == null ? systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX")
: deliveryFolder(recipient.getId(), own))
.flatMap(folder -> { .flatMap(folder -> {
Message message = new Message(); Message message = new Message();
message.setFolderId(folder.getId()); message.setFolderId(folder.getId());
@@ -360,6 +401,16 @@ public class MailService {
.then(messageRepository.delete(message)); .then(messageRepository.delete(message));
} }
/**
* Mailbox deleted for good: attachment files first, then the user row — folders, messages, sources, contacts,
* list memberships and access rows go with it (ON DELETE CASCADE).
*/
public Mono<Void> deleteMailbox(User user) {
return attachmentRepository.findByMailbox(user.getId())
.doOnNext(attachment -> deleteFile(attachment.getFilePath()))
.then(userRepository.delete(user));
}
private static void deleteFile(String path) { private static void deleteFile(String path) {
try { try {
Files.deleteIfExists(Paths.get(path)); Files.deleteIfExists(Paths.get(path));
@@ -439,6 +490,7 @@ public class MailService {
resp.setMessageSize(message.getMessageSize()); resp.setMessageSize(message.getMessageSize());
resp.setReceivedAt(message.getReceivedAt()); resp.setReceivedAt(message.getReceivedAt());
resp.setFolderId(message.getFolderId()); resp.setFolderId(message.getFolderId());
resp.setAnsweredBy(message.getAnsweredBy());
return resp; return resp;
} }
@@ -0,0 +1,133 @@
package com.mailserver.service;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.Collection;
import java.util.Locale;
/**
* Several mailboxes for one login (V7): own, shared ones it is a MEMBER of, ATTACHED ones (by their password).
* One rule for the web client, SMTP and IMAP; in mail programs another mailbox is
* {@code mailbox*login} with the login's password (the Dovecot "master user" form).
*/
@Service
public class MailboxAccess {
public static final String MEMBER = "MEMBER";
public static final String ATTACHED = "ATTACHED";
/** @param kind OWN, MEMBER or ATTACHED; unread — INBOX and own folders (not Spam/Trash/Sent/Drafts) */
public record Entry(String email, String displayName, String kind, boolean shared, long unread) {
}
private final DatabaseClient db;
private final UserRepository users;
private final UserService userService;
private final AppPasswords appPasswords;
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService, AppPasswords appPasswords) {
this.appPasswords = appPasswords;
this.db = db;
this.users = users;
this.userService = userService;
}
/**
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app
* passwords (V9) → the mailbox the session works with, or null. A shared mailbox never logs in by itself.
*/
public User login(String username, String password) {
String name = username.trim().toLowerCase(Locale.ROOT);
int star = name.indexOf('*');
String mailbox = star < 0 ? name : name.substring(0, star);
String login = star < 0 ? name : name.substring(star + 1);
User user = users.findByEmail(login).block();
if (user == null || !user.isActive() || user.isShared()
|| !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) {
return null;
}
if (mailbox.equals(login)) {
return user;
}
User target = users.findByEmail(mailbox).block();
return target != null && target.isActive() && Boolean.TRUE.equals(has(target.getId(), user.getId()).block())
? target : null;
}
/** The login may open this mailbox (own or by access). */
public Mono<Boolean> has(Long mailboxId, Long userId) {
if (mailboxId.equals(userId)) {
return Mono.just(true);
}
return db.sql("SELECT 1 FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u")
.bind("m", mailboxId).bind("u", userId).map((row, meta) -> true).one().defaultIfEmpty(false);
}
/** Own mailbox first, then the others by address. */
public Flux<Entry> mailboxes(User login) {
Mono<Entry> own = unread(login.getId()).map(n -> new Entry(login.getEmail(), login.getDisplayName(), "OWN", false, n));
Flux<Entry> others = db.sql("SELECT u.id, u.email, u.display_name, u.is_shared, a.kind FROM mailbox_access a "
+ "JOIN users u ON u.id = a.mailbox_id WHERE a.user_id = :u AND u.is_active ORDER BY u.email")
.bind("u", login.getId())
.map((row, meta) -> new Object[]{row.get("id", Long.class), row.get("email", String.class),
row.get("display_name", String.class), Boolean.TRUE.equals(row.get("is_shared", Boolean.class)),
row.get("kind", String.class)})
.all()
.concatMap(r -> unread((Long) r[0]).map(n -> new Entry((String) r[1], (String) r[2], (String) r[4], (Boolean) r[3], n)));
return own.flux().concatWith(others);
}
public Mono<Long> unread(Long mailboxId) {
return db.sql("SELECT COUNT(*) AS n FROM messages m JOIN folders f ON f.id = m.folder_id "
+ "WHERE f.user_id = :u AND NOT m.is_read AND NOT m.is_deleted AND (NOT f.is_system OR f.name = 'INBOX')")
.bind("u", mailboxId).map((row, meta) -> row.get("n", Long.class)).one().defaultIfEmpty(0L);
}
public Mono<Void> grant(Long mailboxId, Long userId, String kind) {
return db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) "
+ "ON CONFLICT (mailbox_id, user_id) DO NOTHING")
.bind("m", mailboxId).bind("u", userId).bind("k", kind).then();
}
public Mono<Boolean> revoke(Long mailboxId, Long userId) {
return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND user_id = :u")
.bind("m", mailboxId).bind("u", userId).fetch().rowsUpdated().map(n -> n > 0);
}
/** Exactly these MEMBERs (ATTACHED ones stay — they know the password). */
@Transactional
public Mono<Void> setMembers(Long mailboxId, Collection<Long> memberIds) {
return db.sql("DELETE FROM mailbox_access WHERE mailbox_id = :m AND kind = :k").bind("m", mailboxId).bind("k", MEMBER).then()
.thenMany(Flux.fromIterable(memberIds).distinct().filter(id -> !id.equals(mailboxId))
.concatMap(id -> db.sql("INSERT INTO mailbox_access (mailbox_id, user_id, kind) VALUES (:m, :u, :k) "
+ "ON CONFLICT (mailbox_id, user_id) DO UPDATE SET kind = EXCLUDED.kind")
.bind("m", mailboxId).bind("u", id).bind("k", MEMBER).then()))
.then();
}
public Flux<String> members(Long mailboxId) {
return db.sql("SELECT u.email FROM mailbox_access a JOIN users u ON u.id = a.user_id "
+ "WHERE a.mailbox_id = :m AND a.kind = :k ORDER BY u.email")
.bind("m", mailboxId).bind("k", MEMBER).map((row, meta) -> row.get("email", String.class)).all();
}
/** The login loses every MEMBER access (role removed in Jambotron); ATTACHED stays with the mailbox owner's say. */
public Mono<Long> removeEverywhere(Long userId) {
return db.sql("DELETE FROM mailbox_access WHERE user_id = :u AND kind = :k").bind("u", userId).bind("k", MEMBER)
.fetch().rowsUpdated();
}
/** Reply from the web client: the original is answered, and by whom. */
public Mono<Void> markAnswered(Long messageId, Long mailboxId, String by) {
return db.sql("UPDATE messages SET is_answered = TRUE, answered_by = :b WHERE id = :id "
+ "AND folder_id IN (SELECT id FROM folders WHERE user_id = :u)")
.bind("b", by).bind("id", messageId).bind("u", mailboxId).then();
}
}
@@ -50,9 +50,11 @@ public class SubmissionService {
private final MailService mailService; private final MailService mailService;
private final MailRouter router; private final MailRouter router;
private final String hostname; private final String hostname;
private final MailboxAccess access;
public SubmissionService(UserRepository users, MailService mailService, MailRouter router, public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) { @Value("${mailserver.smtp.hostname:localhost}") String hostname, MailboxAccess access) {
this.access = access;
this.users = users; this.users = users;
this.mailService = mailService; this.mailService = mailService;
this.router = router; this.router = router;
@@ -60,6 +62,11 @@ public class SubmissionService {
} }
public Mono<MessageResponse> send(String fromEmail, SendRequest request) { public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
return send(fromEmail, fromEmail, request);
}
/** @param login who sends (the member for a shared mailbox) — recorded as «answered by» on the original */
public Mono<MessageResponse> send(String fromEmail, String login, SendRequest request) {
List<String> to = addresses(request.getTo()); List<String> to = addresses(request.getTo());
List<String> cc = addresses(request.getCc()); List<String> cc = addresses(request.getCc());
cc.removeAll(to); cc.removeAll(to);
@@ -78,7 +85,9 @@ public class SubmissionService {
.subscribeOn(Schedulers.boundedElastic())) .subscribeOn(Schedulers.boundedElastic()))
.flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc), .flatMap(messageId -> mailService.saveSent(user, String.join(", ", to), String.join(", ", cc),
request.getSubject(), request.getBody(), request.isHtml(), messageId)) request.getSubject(), request.getBody(), request.isHtml(), messageId))
.flatMap(sent -> (draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId())) .flatMap(sent -> (request.getReplyToId() == null ? Mono.<Void>empty()
: access.markAnswered(request.getReplyToId(), user.getId(), login))
.then(draftId == null ? Mono.<Void>empty() : mailService.finishDraft(draftId, sent.getId()))
.then(mailService.getMessage(sent.getId(), user.getId())))); .then(mailService.getMessage(sent.getId(), user.getId()))));
} }
@@ -88,10 +97,10 @@ public class SubmissionService {
for (String address : concat(to, cc)) { for (String address : concat(to, cc)) {
SmtpBackend.Route route = router.route(address, user.getEmail()); SmtpBackend.Route route = router.route(address, user.getEmail());
switch (route) { switch (route) {
case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route)); case LOCAL, OUTBOUND, TRAP -> recipients.add(new SmtpBackend.Recipient(address, route));
case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address); 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 OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address);
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay"); case RELAY_DENIED, LIST_CLOSED -> throw new IllegalStateException("logged-in user denied: " + route);
} }
} }
String messageId = UUID.randomUUID() + "@" + hostname; String messageId = UUID.randomUUID() + "@" + hostname;
@@ -74,6 +74,11 @@ public class UserService {
return userRepository.save(user).then(); return userRepository.save(user).then();
} }
/** The mailbox, or empty (no error). */
public Mono<User> findOptional(String email) {
return userRepository.findByEmail(email.toLowerCase(java.util.Locale.ROOT));
}
public Flux<User> all() { public Flux<User> all() {
return userRepository.findAll(); return userRepository.findAll();
} }
@@ -93,6 +98,26 @@ public class UserService {
return userRepository.save(user); return userRepository.save(user);
} }
public Mono<User> setShared(User user, boolean shared) {
user.setShared(shared);
return userRepository.save(user);
}
public Mono<User> setAdmin(User user, boolean admin) {
user.setAdmin(admin);
return userRepository.save(user);
}
public Mono<User> setSystem(User user, boolean system) {
user.setSystem(system);
return userRepository.save(user);
}
public Mono<User> setTrap(User user, boolean trap) {
user.setTrap(trap);
return userRepository.save(user);
}
public Mono<User> updateDisplayName(User user, String displayName) { public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim()); user.setDisplayName(displayName.trim());
return userRepository.save(user); return userRepository.save(user);
@@ -7,16 +7,26 @@ import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService; import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
/** /**
* Where mail goes — one rule for SMTP and for the web client: * Where mail goes — one rule for SMTP and for the web client:
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox * <ul>
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox → * <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay). * <li>a mailing list of ours ({@code alerts-prod@}) → a copy to every member, into the list's folder
* (or the "+tag" folder); a closed list takes mail only from logged-in senders;</li>
* <li>a TRAP sender (staging@) and an address that is not ours → nothing leaves: one copy into the sender's INBOX
* (our own mailboxes and lists still get their mail);</li>
* <li>catch-all set → everything else into the catch-all mailbox; otherwise our domain → "no such user",
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).</li>
* </ul>
*/ */
@Service @Service
public class MailRouter implements SmtpBackend { public class MailRouter implements SmtpBackend {
@@ -28,9 +38,13 @@ public class MailRouter implements SmtpBackend {
private final OutboundQueue queue; private final OutboundQueue queue;
private final DkimService dkim; private final DkimService dkim;
private final SmtpDeliveryClient client; private final SmtpDeliveryClient client;
private final MailLists lists;
private final MailboxAccess access;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery, public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) { OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists,
MailboxAccess access) {
this.access = access;
this.domains = domains; this.domains = domains;
this.users = users; this.users = users;
this.userService = userService; this.userService = userService;
@@ -38,19 +52,30 @@ public class MailRouter implements SmtpBackend {
this.queue = queue; this.queue = queue;
this.dkim = dkim; this.dkim = dkim;
this.client = client; this.client = client;
this.lists = lists;
} }
@Override @Override
public boolean authenticate(String username, String password) { public String authenticate(String username, String password) {
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block(); User mailbox = access.login(username, password);
return user != null && user.isActive() && userService.checkPassword(user, password); return mailbox == null ? null : mailbox.getEmail();
} }
@Override @Override
public Route route(String recipient, String authenticatedUser) { public Route route(String recipient, String authenticatedUser) {
if (domains.isLocal(recipient) && mailboxExists(recipient)) { if (domains.isLocal(recipient)) {
String base = MailDomains.baseOf(recipient);
if (mailboxExists(recipient) || mailboxExists(base)) {
return Route.LOCAL; return Route.LOCAL;
} }
MailLists.MailList list = lists.find(base).block();
if (list != null) {
return list.openToOutside() || authenticatedUser != null ? Route.LOCAL : Route.LIST_CLOSED;
}
}
if (isTrap(authenticatedUser)) {
return Route.TRAP;
}
if (domains.catchAll() != null) { if (domains.catchAll() != null) {
return Route.LOCAL; return Route.LOCAL;
} }
@@ -66,13 +91,13 @@ public class MailRouter implements SmtpBackend {
@Override @Override
public void accept(Envelope envelope, byte[] message) throws Exception { public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null; boolean trusted = envelope.authenticatedUser() != null;
// one copy per mailbox + folder, even when an address and a list both lead there
Set<Target> targets = new LinkedHashSet<>();
byte[] signed = null; byte[] signed = null;
for (Recipient recipient : envelope.recipients()) { for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) { switch (recipient.route()) {
case LOCAL -> { case LOCAL -> targets.addAll(targets(recipient.address()));
String mailbox = mailbox(recipient.address()); case TRAP -> targets.add(new Target(envelope.authenticatedUser().toLowerCase(Locale.ROOT), null));
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
}
case OUTBOUND -> { case OUTBOUND -> {
if (signed == null) { if (signed == null) {
signed = dkim.sign(message); signed = dkim.sign(message);
@@ -82,14 +107,56 @@ public class MailRouter implements SmtpBackend {
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient); default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
} }
} }
for (Target target : targets) {
localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted);
}
} }
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */ /** Mailbox + folder (null — INBOX) that store mail to a local address. */
String mailbox(String address) { record Target(String mailbox, String folder) {
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
return address;
} }
return domains.catchAll();
Set<Target> targets(String address) {
Set<Target> result = new LinkedHashSet<>();
String lower = address.toLowerCase(Locale.ROOT);
if (domains.isLocal(lower)) {
if (mailboxExists(lower)) {
result.add(new Target(lower, null));
return result;
}
String base = MailDomains.baseOf(lower);
String tag = MailDomains.tagOf(address);
if (mailboxExists(base)) {
result.add(new Target(base, tag));
return result;
}
MailLists.MailList list = lists.find(base).block();
if (list != null) {
for (String member : list.members()) {
result.add(new Target(member, tag != null ? tag : list.folder()));
}
return result;
}
}
if (domains.catchAll() != null) {
result.add(new Target(domains.catchAll(), null));
}
return result;
}
/** Mailbox that stores mail to this address: its own (without "+tag") if it exists here, otherwise the catch-all one. */
String mailbox(String address) {
Set<Target> found = targets(address);
return found.isEmpty() ? address : found.iterator().next().mailbox();
}
/** The logged-in sender is a trap mailbox. */
boolean isTrap(String authenticatedUser) {
if (authenticatedUser == null) {
return false;
}
User user = users.findByEmail(authenticatedUser.toLowerCase(Locale.ROOT)).block();
return user != null && user.isTrap();
} }
private boolean mailboxExists(String address) { private boolean mailboxExists(String address) {
@@ -10,8 +10,12 @@ public interface SmtpBackend {
/** Where a recipient goes, decided at RCPT TO. */ /** Where a recipient goes, decided at RCPT TO. */
enum Route { enum Route {
/** A mailbox of ours (or the catch-all mailbox) — store it. */ /** A mailbox or mailing list of ours (or the catch-all mailbox) — store it. */
LOCAL, LOCAL,
/** The sender is a trap mailbox and the address is not ours: one copy into the sender's own INBOX. */
TRAP,
/** A mailing list of ours that takes mail only from logged-in senders. */
LIST_CLOSED,
/** Someone else's address, sender is logged in — queue for delivery. */ /** Someone else's address, sender is logged in — queue for delivery. */
OUTBOUND, OUTBOUND,
/** Address of our domain that does not exist. */ /** Address of our domain that does not exist. */
@@ -28,8 +32,11 @@ public interface SmtpBackend {
record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) { record Envelope(String remoteIp, String helo, String mailFrom, String authenticatedUser, List<Recipient> recipients) {
} }
/** true — the address and password belong to an active mailbox. */ /**
boolean authenticate(String username, String password); * {@code login} or {@code mailbox*login} with the login's password → the mailbox address the session works as,
* or null.
*/
String authenticate(String username, String password);
Route route(String recipient, String authenticatedUser); Route route(String recipient, String authenticatedUser);
@@ -373,12 +373,13 @@ public class SmtpServer {
closing = true; closing = true;
return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait)); return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
} }
return blocking(() -> backend.authenticate(username, password)) return blocking(() -> java.util.Optional.ofNullable(backend.authenticate(username, password)))
.flatMap(ok -> { .flatMap(found -> {
if (ok) { if (found.isPresent()) {
guard.success(remoteIp, username); guard.success(remoteIp, username);
authenticatedUser = username; authenticatedUser = found.get();
log.info("SMTP {}: authenticated as {}", remoteIp, username); log.info("SMTP {}: authenticated as {}{}", remoteIp, found.get(),
found.get().equalsIgnoreCase(username) ? "" : " (login " + username + ")");
return Mono.just("235 2.7.0 Authentication successful\r\n"); return Mono.just("235 2.7.0 Authentication successful\r\n");
} }
guard.failure(remoteIp, username); guard.failure(remoteIp, username);
@@ -438,7 +439,7 @@ public class SmtpServer {
String user = authenticatedUser; String user = authenticatedUser;
return blocking(() -> backend.route(address, user)) return blocking(() -> backend.route(address, user))
.map(route -> switch (route) { .map(route -> switch (route) {
case LOCAL, OUTBOUND -> { case LOCAL, OUTBOUND, TRAP -> {
recipients.add(new SmtpBackend.Recipient(address, route)); recipients.add(new SmtpBackend.Recipient(address, route));
yield "250 2.1.5 Recipient OK\r\n"; yield "250 2.1.5 Recipient OK\r\n";
} }
@@ -448,6 +449,7 @@ public class SmtpServer {
yield "550 5.7.1 Relaying denied: authentication required\r\n"; yield "550 5.7.1 Relaying denied: authentication required\r\n";
} }
case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n"; case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n";
case LIST_CLOSED -> "550 5.7.1 This list takes mail only from logged-in senders\r\n";
}); });
} }
@@ -18,11 +18,20 @@ public class JwtUtils {
} }
public String generateToken(String email) { public String generateToken(String email) {
return generateToken(email, null);
}
/**
* @param by the login that opened this mailbox (shared / attached mailbox, administrator's OPEN), or null —
* the mailbox itself logged in
*/
public String generateToken(String email, String by) {
Date now = new Date(); Date now = new Date();
Date expiry = new Date(now.getTime() + EXPIRATION_MS); Date expiry = new Date(now.getTime() + EXPIRATION_MS);
return Jwts.builder() return Jwts.builder()
.subject(email) .subject(email)
.claim("by", by)
.issuedAt(now) .issuedAt(now)
.expiration(expiry) .expiration(expiry)
.signWith(signingKey) .signWith(signingKey)
@@ -38,6 +47,12 @@ public class JwtUtils {
return claims.getSubject(); return claims.getSubject();
} }
/** "by" claim: who opened this mailbox, or null. */
public String getByFromToken(String token) {
Object by = Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token).getPayload().get("by");
return by == null ? null : by.toString();
}
public boolean isValidToken(String token) { public boolean isValidToken(String token) {
try { try {
Jwts.parser() Jwts.parser()
@@ -31,6 +31,13 @@ mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
mailserver.admins=${MAIL_ADMINS:} mailserver.admins=${MAIL_ADMINS:}
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once # 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.passwords-file=${MAIL_PASSWORDS_FILE:}
# Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off.
# In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN).
mailserver.service-token=${SERVICE_TOKEN:}
# Sign in with Jambotron (OAuth 2.0 code + PKCE): https://jambotron.com; empty — password login for everyone
mailserver.jambotron.url=${JAMBOTRON_URL:}
# address of this web client for the OAuth callback; empty — https://MAIL_HOSTNAME
mailserver.public-url=${MAIL_PUBLIC_URL:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments # Attachments
@@ -0,0 +1,3 @@
-- Trap mailbox: what it sends is NOT delivered to anyone — every copy lands in its own INBOX (staging@: test mail
-- of a staging stand never reaches real people). Switched on by an administrator in Settings → MAILBOXES.
ALTER TABLE users ADD COLUMN is_trap BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,18 @@
-- Mailing lists (alerts-prod@ …): mail to the list address → a copy into the mailbox of EVERY member,
-- into the list's folder (created on first mail). Each member has their own copy — their own read/unread.
-- open_to_outside = false: only logged-in senders (Kuma, Jambotron) may write to the list.
CREATE TABLE mail_lists (
id BIGSERIAL PRIMARY KEY,
address VARCHAR(255) UNIQUE NOT NULL,
folder VARCHAR(50),
description VARCHAR(255),
open_to_outside BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE mail_list_members (
list_id BIGINT NOT NULL REFERENCES mail_lists(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (list_id, user_id)
);
CREATE INDEX idx_mail_list_members_user ON mail_list_members(user_id);
@@ -0,0 +1,19 @@
-- Several mailboxes for one login (75.9):
-- shared mailbox (support@, billing@): no login of its own, members open it from their mailbox; read/unread is
-- common — a team inbox; answered_by shows who replied;
-- attached mailbox: a login adds another mailbox it knows the password of.
ALTER TABLE users ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE mailbox_access (
mailbox_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- MEMBER: given by an administrator / Jambotron; ATTACHED: added by the user with the mailbox password
kind VARCHAR(10) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (mailbox_id, user_id),
CHECK (mailbox_id <> user_id)
);
CREATE INDEX idx_mailbox_access_user ON mailbox_access(user_id);
-- who answered a message (web client reply; mostly for shared mailboxes)
ALTER TABLE messages ADD COLUMN answered_by VARCHAR(255);
@@ -0,0 +1,3 @@
-- Mail administrator given by Jambotron (service API): every ADMIN of Jambotron manages mailboxes here too.
-- Only the service token sets it — a mailbox can not make itself an administrator. Plus mailserver.admins.
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,15 @@
-- Sign in with Jambotron (OAuth 2.0 code + PKCE): people log in through Jambotron; passwords stay for mail programs.
-- App passwords: one per program (Thunderbird, phone, Kuma …), SMTP / IMAP only, shown once, revoked one by one.
CREATE TABLE app_passwords (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
hash VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMP
);
CREATE INDEX app_passwords_user ON app_passwords(user_id);
-- System mailbox (no-reply@, monitor@, staging@): no person behind it, no web login, its owners (built-in
-- administrators) open it from their own mailbox; programs use its password / app passwords.
ALTER TABLE users ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,19 @@
package com.mailserver.config;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** "+tag" addresses: the base address is the mailbox, the tag is the folder. */
class MailDomainsTest {
@Test
void baseAndTag() {
assertThat(MailDomains.baseOf("Bob+Prod@Mail.test")).isEqualTo("bob@mail.test");
assertThat(MailDomains.tagOf("Bob+Prod@Mail.test")).isEqualTo("Prod");
assertThat(MailDomains.baseOf("bob@mail.test")).isEqualTo("bob@mail.test");
assertThat(MailDomains.tagOf("bob@mail.test")).isNull();
assertThat(MailDomains.tagOf("bob+@mail.test")).isNull();
assertThat(MailDomains.tagOf("+x@mail.test")).isNull();
}
}
@@ -33,8 +33,9 @@ class AuthenticationControllerTest {
private final JwtUtils jwt = mock(JwtUtils.class); private final JwtUtils jwt = mock(JwtUtils.class);
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15), private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO)); Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("admin@x.test", "", new MailDomains("x.test", ""));
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt, private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class)); mock(MailDomains.class), false, guard, admins, new com.mailserver.config.JambotronSso("", "", "", "mail.x.test"));
private final User alice = new User(); private final User alice = new User();
@@ -56,6 +57,26 @@ class AuthenticationControllerTest {
return controller.login(Mono.just(request), http).block(); return controller.login(Mono.just(request), http).block();
} }
// «Sign in with Jambotron» on: the web password only for the built-in administrator
@Test
void withJambotronSignInOnlyTheBuiltInAdministratorLogsInWithAPassword() {
AuthenticationController sso = new AuthenticationController(users, mock(MailService.class), jwt,
mock(MailDomains.class), false, guard, admins,
new com.mailserver.config.JambotronSso("https://jambotron.test", "tok", "", "mail.x.test"));
User admin = new User();
admin.setEmail("admin@x.test");
when(users.findByEmail("admin@x.test")).thenReturn(Mono.just(admin));
when(users.checkPassword(eq(admin), anyString())).thenReturn(true);
LoginRequest request = new LoginRequest();
request.setEmail("alice@x.test");
request.setPassword("secret");
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login").remoteAddress(new InetSocketAddress("9.9.9.9", 1)).build();
assertThat(sso.login(Mono.just(request), http).block().getBody())
.isEqualTo(Map.of("error", "Sign in with Jambotron — the password is only for mail programs"));
request.setEmail("admin@x.test");
assertThat(sso.login(Mono.just(request), http).block().getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test @Test
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() { void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials")); assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
@@ -33,16 +33,29 @@ class MailboxAdminControllerTest {
private final MailService mail = mock(MailService.class); private final MailService mail = mock(MailService.class);
private final MailDomains domains = new MailDomains("jambocheck.com", ""); private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailAdmins admins = new MailAdmins("", "", domains); private final MailAdmins admins = new MailAdmins("", "", domains);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins); private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
private final com.mailserver.service.MailLists lists = mock(com.mailserver.service.MailLists.class);
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains, access);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists, access);
private final User admin = user(1L, "admin@jambocheck.com"); private final User admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com"); private final User alice = user(2L, "alice@jambocheck.com");
MailboxAdminControllerTest() { MailboxAdminControllerTest() {
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L)); when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
when(lists.exists(anyString())).thenReturn(Mono.just(false));
when(access.members(anyLong())).thenReturn(Flux.empty());
when(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true));
when(users.findOptional(anyString())).thenReturn(Mono.empty());
when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice));
when(lists.save(anyString(), any(), any(), any(Boolean.class), any())).thenAnswer(i ->
Mono.just(new com.mailserver.service.MailLists.MailList(5L, i.getArgument(0), i.getArgument(1), i.getArgument(2),
i.getArgument(3), List.of("alice@jambocheck.com"))));
when(users.all()).thenReturn(Flux.just(alice, admin)); when(users.all()).thenReturn(Flux.just(alice, admin));
when(users.byId(1L)).thenReturn(Mono.just(admin)); when(users.byId(1L)).thenReturn(Mono.just(admin));
when(users.byId(2L)).thenReturn(Mono.just(alice)); when(users.byId(2L)).thenReturn(Mono.just(alice));
when(users.byId(99L)).thenReturn(Mono.empty());
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0)))); when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0))); when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> { when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
@@ -50,6 +63,61 @@ class MailboxAdminControllerTest {
u.setActive(i.getArgument(1)); u.setActive(i.getArgument(1));
return Mono.just(u); return Mono.just(u);
}); });
when(users.setTrap(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setTrap(i.getArgument(1));
return Mono.just(u);
});
}
@Test
void mailboxAndListShareOneAddressSpaceNoPlusInNames() {
assertThat(status(() -> create("admin@jambocheck.com", "alerts@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
assertThat(status(() -> create("admin@jambocheck.com", "bob+x@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(create("admin@jambocheck.com", "jambotron_prod@jambocheck.com", "").mailbox().email()).isEqualTo("jambotron_prod@jambocheck.com");
var request = new MailboxAdminController.ListRequest("prod", "alerts", false, List.of("Alice@jambocheck.com"));
assertThat(controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block().members())
.containsExactly("alice@jambocheck.com");
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alice@jambocheck.com", Mono.just(request)).block()))
.isEqualTo(HttpStatus.CONFLICT);
var unknown = new MailboxAdminController.ListRequest("prod", null, false, List.of("ghost@jambocheck.com"));
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(unknown)).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
var badFolder = new MailboxAdminController.ListRequest("Trash", null, false, List.of());
assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(badFolder)).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.saveList(as("alice@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void sharedMailboxHasNoPasswordShownMembersAreSet() {
when(users.setShared(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setShared(i.getArgument(1));
return Mono.just(u);
});
var created = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest("support@jambocheck.com", "Support", "", true))).block();
assertThat(created.password()).isNull();
assertThat(created.mailbox().shared()).isTrue();
when(access.setMembers(anyLong(), any())).thenReturn(Mono.empty());
controller.members(as("admin@jambocheck.com"), 2L,
Mono.just(new MailboxAdminController.MembersRequest(List.of("Alice@jambocheck.com")))).block();
verify(access).setMembers(eq(2L), eq(List.of(2L)));
assertThat(status(() -> controller.members(as("admin@jambocheck.com"), 2L,
Mono.just(new MailboxAdminController.MembersRequest(List.of("ghost@jambocheck.com")))).block())).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
assertThat(alice.isTrap()).isTrue();
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(false))).block().trap()).isFalse();
assertThat(status(() -> controller.trap(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
} }
@Test @Test
@@ -71,14 +139,14 @@ class MailboxAdminControllerTest {
@Test @Test
void createsReservedNamesWithAGeneratedPasswordShownOnce() { void createsReservedNamesWithAGeneratedPasswordShownOnce() {
var created = controller.create(as("admin@jambocheck.com"), var created = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block(); Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", "", null))).block();
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com"); assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20); assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron")); verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
var chosen = controller.create(as("admin@jambocheck.com"), var chosen = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block(); Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1", null))).block();
assertThat(chosen.password()).isNull(); assertThat(chosen.password()).isNull();
} }
@@ -105,8 +173,59 @@ class MailboxAdminControllerTest {
.isEqualTo(HttpStatus.BAD_REQUEST); .isEqualTo(HttpStatus.BAD_REQUEST);
} }
@Test
void administratorDeletesAMailboxButNotHisOwnOthersCanNot() {
when(mail.deleteMailbox(any())).thenReturn(Mono.empty());
controller.delete(as("admin@jambocheck.com"), 2L).block();
verify(mail).deleteMailbox(alice);
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.delete(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
verify(mail, never()).deleteMailbox(admin);
}
@Test
void administratorGivesAndTakesRoleAdminNotOwnNotBuiltInNotShared() {
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setAdmin(i.getArgument(1));
return Mono.just(u);
});
var made = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block();
assertThat(made.role()).isEqualTo("ADMIN");
assertThat(admins.isAdmin("alice@jambocheck.com")).isTrue();
// alice is ADMIN now — she manages roles too, but not the built-in one and not her own
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
var back = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block();
assertThat(back.role()).isEqualTo("USER");
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
alice.setShared(true);
assertThat(status(() -> controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
assertThat(session.getToken()).isEqualTo("alice-token");
assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
}
private MailboxAdminController.Created create(String me, String email, String password) { private MailboxAdminController.Created create(String me, String email, String password) {
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block(); return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password, null))).block();
} }
private static Mono<Authentication> as(String email) { private static Mono<Authentication> as(String email) {
@@ -0,0 +1,130 @@
package com.mailserver.controller;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Service API for Jambotron: token only; account created once with a password; switched off → out of the lists. */
class ServiceControllerTest {
private static final String AUTH = "Bearer s3cret-token";
private final UserService users = mock(UserService.class);
private final MailLists lists = mock(MailLists.class);
private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
private final MailDirectory directory = new MailDirectory(users, lists, domains, access);
private final User existing = user(7L, "jambotronpw@jambocheck.com");
private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("", "", domains);
ServiceControllerTest() {
when(users.findOptional(anyString())).thenReturn(Mono.empty());
when(users.findOptional("jambotronpw@jambocheck.com")).thenReturn(Mono.just(existing));
when(lists.exists(anyString())).thenReturn(Mono.just(false));
when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com"));
when(lists.removeEverywhere(anyLong())).thenReturn(Mono.just(1L));
when(access.removeEverywhere(anyLong())).thenReturn(Mono.just(1L));
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(8L, i.getArgument(0))));
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setActive(i.getArgument(1));
return Mono.just(u);
});
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setAdmin(i.getArgument(1));
return Mono.just(u);
});
}
// Jambotron ADMIN → mail administrator (Settings → MAILBOXES), and back
@Test
void jambotronMakesAMailboxAdministratorAndTakesItBack() {
assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse();
var made = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, true, true))).block();
assertThat(made.getBody().account().admin()).isTrue();
assertThat(admins.isAdmin("JambotronPW@jambocheck.com")).isTrue();
api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, true, false))).block();
assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse();
assertThat(admins.isAdmin("admin@jambocheck.com")).isTrue();
}
private ServiceController api(String token) {
return new ServiceController(token, users, lists, directory, access, admins);
}
@Test
void offWithoutTokenWrongTokenRefused() {
assertThat(status(() -> api("").account(AUTH, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(status(() -> api("s3cret-token").account("Bearer nope", "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(status(() -> api("s3cret-token").account(null, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(api("s3cret-token").account(AUTH, "jambotronpw@jambocheck.com").block().lists())
.containsExactly("alerts-prod@jambocheck.com");
}
@Test
void newAccountGetsPasswordOnceExistingDoesNot() {
var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com",
Mono.just(new ServiceController.AccountRequest("Moder", true, null))).block();
assertThat(created.getStatusCode().value()).isEqualTo(201);
assertThat(created.getBody().password()).hasSizeGreaterThan(20);
var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, null, null))).block();
assertThat(again.getStatusCode().value()).isEqualTo(200);
assertThat(again.getBody().password()).isNull();
verify(lists, never()).removeEverywhere(anyLong());
assertThat(status(() -> api("s3cret-token").saveAccount(AUTH, "x@gmail.com", Mono.empty()))).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void switchedOffLeavesEveryList() {
var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, false, null))).block();
assertThat(off.getBody().account().active()).isFalse();
verify(lists).removeEverywhere(7L);
verify(access).removeEverywhere(7L);
var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block();
assertThat(reset.password()).isNotBlank();
verify(users).setPassword(existing, reset.password());
}
private static HttpStatus status(Runnable call) {
Throwable error = catchThrowable(call::run);
assertThat(error).isInstanceOf(ResponseStatusException.class);
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
}
private static User user(Long id, String email) {
User user = new User();
user.setId(id);
user.setEmail(email);
user.setPassword("hash");
return user;
}
}
@@ -0,0 +1,15 @@
package com.mailserver.controller;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SsoControllerTest {
// RFC 7636 appendix B
@Test
void pkceChallengeIsS256OfTheVerifier() {
assertThat(SsoController.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))
.isEqualTo("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
}
@@ -0,0 +1,24 @@
package com.mailserver.security;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/** The login behind a session: own session, or a member who opened a shared mailbox. */
class ActorTest {
@Test
void ownAndOpened() {
var own = new UsernamePasswordAuthenticationToken("bob@x", null, List.of());
assertThat(Actor.login(own)).isEqualTo("bob@x");
assertThat(Actor.opened(own)).isFalse();
var opened = new UsernamePasswordAuthenticationToken("support@x", null, List.of());
opened.setDetails("bob@x");
assertThat(Actor.login(opened)).isEqualTo("bob@x");
assertThat(Actor.opened(opened)).isTrue();
}
}
@@ -0,0 +1,21 @@
package com.mailserver.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** A "+tag" never puts mail into a system folder and never makes a strange folder name. */
class DeliveryFolderTest {
@Test
void onlyPlainOwnFolderNames() {
assertThat(MailService.deliveryFolderName("prod")).isEqualTo("prod");
assertThat(MailService.deliveryFolderName("alerts.db-1")).isEqualTo("alerts.db-1");
assertThat(MailService.deliveryFolderName(null)).isNull();
assertThat(MailService.deliveryFolderName("inbox")).isNull();
assertThat(MailService.deliveryFolderName("Trash")).isNull();
assertThat(MailService.deliveryFolderName("spam")).isNull();
assertThat(MailService.deliveryFolderName("../x")).isNull();
assertThat(MailService.deliveryFolderName("a".repeat(51))).isNull();
}
}
@@ -3,9 +3,11 @@ package com.mailserver.smtp;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue; import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService; import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -17,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -29,13 +32,33 @@ class MailRouterTest {
private final LocalDelivery localDelivery = mock(LocalDelivery.class); private final LocalDelivery localDelivery = mock(LocalDelivery.class);
private final OutboundQueue queue = mock(OutboundQueue.class); private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class); private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private final MailLists lists = mock(MailLists.class);
private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF;
private static User user(String email, boolean trap) {
User user = new User();
user.setEmail(email);
user.setTrap(trap);
return user;
}
private MailRouter router(String catchAll) { private MailRouter router(String catchAll) {
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false)); when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true)); when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF); when(users.findByEmail(anyString())).thenReturn(Mono.empty());
when(users.findByEmail("bob@mail.test")).thenReturn(Mono.just(user("bob@mail.test", false)));
when(users.findByEmail("staging@mail.test")).thenReturn(Mono.just(user("staging@mail.test", true)));
when(users.existsByEmail("staging@mail.test")).thenReturn(Mono.just(true));
when(users.existsByEmail("ann@mail.test")).thenReturn(Mono.just(true));
when(client.mode()).thenReturn(mode);
when(lists.find(anyString())).thenReturn(Mono.empty());
when(lists.find("alerts-prod@mail.test")).thenReturn(Mono.just(new MailLists.MailList(1L, "alerts-prod@mail.test",
"prod", null, false, List.of("bob@mail.test", "ann@mail.test"))));
when(lists.find("talk@mail.test")).thenReturn(Mono.just(new MailLists.MailList(2L, "talk@mail.test",
null, null, true, List.of("bob@mail.test"))));
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery, return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
queue, mock(DkimService.class), client); queue, mock(DkimService.class), client, lists, mock(com.mailserver.service.MailboxAccess.class));
} }
@Test @Test
@@ -61,8 +84,70 @@ class MailRouterTest {
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL), 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()); 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("trap@mail.test"), isNull(), 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(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any()); verify(queue, never()).enqueue(any(), any(), any());
} }
@Test
void trapMailboxKeepsOutsideMailButOurAddressesStillGetIt() throws Exception {
mode = SmtpDeliveryClient.Mode.MX;
MailRouter router = router("");
// an ordinary mailbox still sends outside; the trap one keeps outside mail, ours is delivered
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND);
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
assertThat(router.route("user@gmail.com", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
assertThat(router.route("nobody@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP);
assertThat(router.route("bob@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("alerts-prod@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "staging@mail.test", "staging@mail.test", List.of(
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.TRAP),
new SmtpBackend.Recipient("other@yahoo.com", SmtpBackend.Route.TRAP),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("staging@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(queue, never()).enqueue(any(), any(), any());
}
@Test
void plusTagGoesToThatFolderOfTheMailbox() throws Exception {
MailRouter router = router("");
assertThat(router.route("bob+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
router.accept(new SmtpBackend.Envelope("10.0.0.5", "x", "a@b.c", null, List.of(
new SmtpBackend.Recipient("Bob+Prod@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("Prod"), eq("10.0.0.5"), eq("a@b.c"), eq(false));
}
@Test
void listGivesEveryMemberOwnCopyInTheListFolderClosedListOnlyForLoggedIn() throws Exception {
MailRouter router = router("");
assertThat(router.route("alerts-prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LIST_CLOSED);
assertThat(router.route("alerts-prod@mail.test", "monitor@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("talk@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
// list + one member directly: the member gets the list copy AND the direct one (different folders), once each
router.accept(new SmtpBackend.Envelope("10.0.0.5", "kuma", "monitor@mail.test", "monitor@mail.test", List.of(
new SmtpBackend.Recipient("alerts-prod@mail.test", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("ALERTS-PROD@mail.test", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: down\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("prod"), any(), any(), eq(true));
verify(localDelivery).deliver(any(), eq("ann@mail.test"), eq("prod"), any(), any(), eq(true));
verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), any(), any(), eq(true));
}
@Test
void listTagOverridesTheListFolder() {
MailRouter router = router("");
assertThat(router.targets("alerts-prod+db@mail.test")).containsExactly(
new MailRouter.Target("bob@mail.test", "db"), new MailRouter.Target("ann@mail.test", "db"));
}
} }
@@ -43,8 +43,8 @@ class SmtpServerTest {
volatile boolean failStore; volatile boolean failStore;
@Override @Override
public boolean authenticate(String username, String password) { public String authenticate(String username, String password) {
return password.equals(passwords.get(username)); return password.equals(passwords.get(username)) ? username : null;
} }
@Override @Override
+2 -2
View File
@@ -19,11 +19,11 @@ services:
- -c - -c
- | - |
umask 077 umask 077
for name in DB_PASSWORD JWT_SECRET; do for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done done
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only # postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes: volumes:
- secrets:/secrets - secrets:/secrets
+2 -2
View File
@@ -16,11 +16,11 @@ services:
- -c - -c
- | - |
umask 077 umask 077
for name in DB_PASSWORD JWT_SECRET; do for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done done
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only # postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes: volumes:
- secrets:/secrets - secrets:/secrets
+4 -2
View File
@@ -35,10 +35,10 @@ services:
- -c - -c
- | - |
umask 077 umask 077
for name in DB_PASSWORD JWT_SECRET; do for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do
[ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done done
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes: volumes:
- secrets:/secrets - secrets:/secrets
@@ -128,6 +128,8 @@ services:
environment: environment:
DB_HOST: db DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets MAILSERVER_SECRETS_DIR: /run/mail-secrets
# «Sign in with Jambotron» (OAuth code + PKCE, SERVICE_TOKEN as the client secret); empty — password login
JAMBOTRON_URL: ${JAMBOTRON_URL:-https://jambotron.com}
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com} MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
MAIL_TLS_CERT: /tls/cert.pem MAIL_TLS_CERT: /tls/cert.pem
+3 -1
View File
@@ -3,6 +3,7 @@ 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', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
{ path: 'sso', loadComponent: () => import('./pages/auth/sso.page').then(m => m.SsoPage) },
{ path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) }, { path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
{ {
path: '', path: '',
@@ -15,7 +16,8 @@ export const routes: Routes = [
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) }, { 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: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) }, { path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) } { path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) },
{ path: 'admin', loadComponent: () => import('./pages/admin/admin.page').then(m => m.AdminPage) }
] ]
}, },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' }
+46 -5
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models'; import { AppPassword, Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models';
/** The mail server REST API (same origin, /api). */ /** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -19,15 +19,56 @@ export class ApiService {
// mailboxes — administrators only // mailboxes — administrators only
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); } mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> { createMailbox(email: string, displayName: string, password: string, shared = false): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password }); return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password, shared });
}
setMailboxMembers(id: number, members: string[]): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/members`, { members });
}
// the logged-in user's mailboxes (left menu)
myMailboxes(): Observable<MailboxEntry[]> { return this.http.get<MailboxEntry[]>('/api/mailboxes'); }
mailboxSession(email: string): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/mailboxes/${encodeURIComponent(email)}/session`, {});
}
attachMailbox(email: string, password: string): Observable<MailboxEntry> {
return this.http.post<MailboxEntry>('/api/mailboxes/attach', { email, password });
}
leaveMailbox(email: string): Observable<void> {
return this.http.delete<void>(`/api/mailboxes/${encodeURIComponent(email)}`);
} }
newMailboxPassword(id: number): Observable<MailboxCreated> { newMailboxPassword(id: number): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {}); return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
} }
openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
}
mailLists(): Observable<MailList[]> { return this.http.get<MailList[]>('/api/admin/mailboxes/lists'); }
saveMailList(address: string, list: { folder: string; description: string; openToOutside: boolean; members: string[] }): Observable<MailList> {
return this.http.put<MailList>(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`, list);
}
deleteMailList(address: string): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`);
}
setMailboxTrap(id: number, trap: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap });
}
setMailboxActive(id: number, active: boolean): Observable<Mailbox> { setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active }); return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
} }
setMailboxSystem(id: number, system: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/system`, { system });
}
appPasswords(): Observable<AppPassword[]> { return this.http.get<AppPassword[]>('/api/app-passwords'); }
createAppPassword(name: string): Observable<{ appPassword: AppPassword; password: string }> {
return this.http.post<{ appPassword: AppPassword; password: string }>('/api/app-passwords', { name });
}
deleteAppPassword(id: number): Observable<void> { return this.http.delete<void>(`/api/app-passwords/${id}`); }
setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/role`, { role });
}
deleteMailbox(id: number): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
}
// folders // folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); } folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
@@ -54,8 +95,8 @@ export class ApiService {
// compose // compose
createDraft(draft: Draft): Observable<Message> { return this.http.post<Message>('/api/drafts', draft); } 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); } updateDraft(id: number, draft: Draft): Observable<Message> { return this.http.put<Message>(`/api/drafts/${id}`, draft); }
send(draft: Draft, draftId: number | null): Observable<Message> { send(draft: Draft, draftId: number | null, replyToId: number | null = null): Observable<Message> {
return this.http.post<Message>('/api/send', { ...draft, draftId }); return this.http.post<Message>('/api/send', { ...draft, draftId, replyToId });
} }
// attachments // attachments
+49
View File
@@ -12,6 +12,8 @@ export class AuthService {
private router = inject(Router); private router = inject(Router);
readonly email = signal<string | null>(read('email')); readonly email = signal<string | null>(read('email'));
/** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */
readonly openedBy = signal<string | null>(read('adminEmail'));
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null); readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
login(email: string, password: string): Observable<TokenResponse> { login(email: string, password: string): Observable<TokenResponse> {
@@ -24,6 +26,50 @@ export class AuthService {
token(): string | null { return read('token'); } token(): string | null { return read('token'); }
/** Administrator opens another mailbox: its session, the admin session kept to come back. */
openAs(response: TokenResponse): void {
const adminToken = read('token');
const adminEmail = read('email');
try {
if (adminToken && adminEmail && !read('adminToken')) {
localStorage.setItem('adminToken', adminToken);
localStorage.setItem('adminEmail', adminEmail);
}
} catch { /* storage blocked */ }
this.store(response);
this.openedBy.set(read('adminEmail'));
}
/**
* Switch to another mailbox of the same login (left menu). Going back to the mailbox an administrator came
* from ends the administrator's visit.
*/
switchTo(response: TokenResponse): void {
if (this.openedBy() && response.email === this.openedBy()) {
this.backToAdmin();
return;
}
this.store(response);
}
/** Back to the administrator's own mailbox. */
backToAdmin(): void {
const token = read('adminToken');
const email = read('adminEmail');
try {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ }
this.openedBy.set(null);
if (token && email) { this.store({ token, email }); } else { this.clear(); }
}
/** Session from «Sign in with Jambotron» (/sso#token=…). */
signIn(response: TokenResponse): void {
this.clear();
this.store(response);
}
logout(): void { logout(): void {
this.clear(); this.clear();
this.router.navigate(['/login']); this.router.navigate(['/login']);
@@ -33,8 +79,11 @@ export class AuthService {
try { try {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('email'); localStorage.removeItem('email');
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ } } catch { /* storage blocked */ }
this.email.set(null); this.email.set(null);
this.openedBy.set(null);
} }
private store(response: TokenResponse): void { private store(response: TokenResponse): void {
+8 -1
View File
@@ -1,6 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { Folder, Profile } from './models'; import { Folder, MailboxEntry, Profile } from './models';
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */ /** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -9,6 +9,8 @@ export class MailStateService {
readonly folders = signal<Folder[]>([]); readonly folders = signal<Folder[]>([]);
readonly profile = signal<Profile | null>(null); readonly profile = signal<Profile | null>(null);
/** own, shared and attached mailboxes of the login — sidebar combobox, Settings → MY MAILBOXES */
readonly boxes = signal<MailboxEntry[]>([]);
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null); readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0); readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
@@ -28,8 +30,13 @@ export class MailStateService {
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} }); this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
} }
refreshBoxes(): void {
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
}
reset(): void { reset(): void {
this.folders.set([]); this.folders.set([]);
this.profile.set(null); this.profile.set(null);
this.boxes.set([]);
} }
} }
+35
View File
@@ -23,6 +23,16 @@ export interface Message {
receivedAt: string; receivedAt: string;
hasAttachments: boolean; hasAttachments: boolean;
attachmentCount: number; attachmentCount: number;
answeredBy?: string | null; // login that answered it from the web client (shared mailboxes)
}
/** MailboxAccess.Entry — a mailbox of the logged-in user (left menu) */
export interface MailboxEntry {
email: string;
displayName: string;
kind: 'OWN' | 'MEMBER' | 'ATTACHED';
shared: boolean;
unread: number;
} }
export interface Attachment { export interface Attachment {
@@ -63,9 +73,25 @@ export interface Mailbox {
displayName: string; displayName: string;
active: boolean; active: boolean;
admin: boolean; admin: boolean;
trap: boolean; // what it sends to outside addresses stays in its own INBOX (staging@)
shared: boolean; // shared mailbox: no login of its own, members open it
members: string[]; // who may open it
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; createdAt: string;
role: 'ADMIN' | 'USER'; // ADMIN — Admin panel (users, mailboxes, lists)
builtIn: boolean; // administrator from the server configuration — stays ADMIN
system: boolean; // no-reply@, monitor@ …: no web login, the built-in administrators open it
}
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
export interface MailList {
id: number;
address: string;
folder: string | null;
description: string | null;
openToOutside: boolean;
members: string[];
} }
/** password — generated by the server, shown once; null when the administrator chose it */ /** password — generated by the server, shown once; null when the administrator chose it */
@@ -77,6 +103,15 @@ export interface MailboxCreated {
export interface ServerConfig { export interface ServerConfig {
registrationEnabled: boolean; registrationEnabled: boolean;
domains: string[]; domains: string[];
ssoEnabled?: boolean; // «Sign in with Jambotron»; then the web password is only for the built-in administrator
}
/** AppPasswords.AppPassword — a password for one mail program (SMTP / IMAP only) */
export interface AppPassword {
id: number;
name: string;
createdAt: string;
lastUsedAt: string | null;
} }
export interface Draft { export interface Draft {
@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MailStateService } from '../../core/mail-state.service';
import { ListsSection } from '../settings/lists.section';
import { MailboxesSection } from '../settings/mailboxes.section';
import { UsersSection } from './users.section';
/** Admin panel (role ADMIN): users and their roles, mailboxes, mailing lists. */
@Component({
selector: 'app-admin',
imports: [MatIconModule, UsersSection, MailboxesSection, ListsSection],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (state.profile(); as p) {
@if (p.admin) {
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
<app-users-section />
<app-mailboxes-section />
<app-lists-section />
</div>
} @else {
<section class="glass-panel static p-6 text-slate-300 font-rajdhani" role="alert">
<mat-icon class="align-middle text-pink-500">lock</mat-icon> Admin panel is only for users with the role ADMIN.
</section>
}
}
`
})
export class AdminPage {
readonly state = inject(MailStateService);
}
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Admin panel → USERS: people with a login of their own (not shared or system mailboxes) and their role — ADMIN or USER. */
@Component({
selector: 'app-users-section',
imports: [MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block xl:col-span-2' },
template: `
<section class="glass-panel static p-5 md:p-6" aria-labelledby="users-title">
<h2 id="users-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-1 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">manage_accounts</mat-icon> USERS
</h2>
<p class="text-xs text-slate-400 font-mono mb-3">ADMIN — this Admin panel: users, mailboxes, lists. USER — own mail only.</p>
@if (error()) { <p class="text-sm text-rose-300 mb-2" role="alert">{{ error() }}</p> }
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-[11px] font-mono text-slate-400 border-b border-cyan-500/20">
<th class="py-2 pr-3 font-normal">User</th>
<th class="py-2 pr-3 font-normal">Name</th>
<th class="py-2 pr-3 font-normal">Role</th>
<th class="py-2 pr-3 font-normal">Status</th>
<th class="py-2 font-normal"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
@for (u of users(); track u.id) {
<tr class="border-b border-cyan-500/10">
<td class="py-2 pr-3 font-mono" [class.text-slate-500]="!u.active">{{ u.email }}</td>
<td class="py-2 pr-3 text-slate-300">{{ u.displayName }}</td>
<td class="py-2 pr-3">
<span class="text-[11px] font-mono px-1.5 rounded border"
[class.role-admin]="u.role === 'ADMIN'" [class.border-slate-500]="u.role !== 'ADMIN'"
[class.text-slate-300]="u.role !== 'ADMIN'">{{ u.role }}</span>
@if (u.builtIn) { <span class="text-[10px] font-mono text-slate-500 ml-1" title="From the server configuration">built-in</span> }
</td>
<td class="py-2 pr-3 text-xs font-mono" [class.text-emerald-300]="u.active" [class.text-slate-500]="!u.active">{{ u.active ? 'active' : 'off' }}</td>
<td class="py-2 text-right whitespace-nowrap">
@if (u.email !== me() && !(u.builtIn && u.role === 'ADMIN')) {
<button type="button" class="ghost-btn !py-1 text-xs" (click)="toggle(u)"
[attr.aria-label]="(u.role === 'ADMIN' ? 'Make USER: ' : 'Make ADMIN: ') + u.email">
<mat-icon>{{ u.role === 'ADMIN' ? 'person' : 'admin_panel_settings' }}</mat-icon>
{{ u.role === 'ADMIN' ? 'MAKE USER' : 'MAKE ADMIN' }}</button>
}
</td>
</tr>
} @empty {
<tr><td colspan="5" class="py-3 text-slate-500 font-mono text-xs">No users</td></tr>
}
</tbody>
</table>
</div>
</section>
`
})
export class UsersSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
private readonly all = signal<Mailbox[]>([]);
readonly users = computed(() => this.all().filter(m => !m.shared && !m.system));
readonly me = computed(() => this.state.profile()?.email ?? '');
readonly error = signal('');
constructor() {
this.load();
}
load(): void {
this.api.mailboxes().subscribe({ next: list => this.all.set(list), error: e => this.error.set(errorText(e)) });
}
toggle(u: Mailbox): void {
const role = u.role === 'ADMIN' ? 'USER' : 'ADMIN';
const text = role === 'ADMIN'
? `${u.email} will manage users, mailboxes and lists (Admin panel).`
: `${u.email} keeps only own mail, no Admin panel.`;
this.dialogs.confirm(`Role ${role} for ${u.email}?`, text, 'Change role', role === 'USER').subscribe(ok => {
if (ok === null) return;
this.api.setMailboxRole(u.id, role).subscribe({
next: () => { this.toasts.ok(u.email + ' is ' + role + ' now'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
});
}
}
+27 -4
View File
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service'; import { AuthService } from '../../core/auth.service';
@@ -12,8 +12,24 @@ import { AuthFrameComponent } from './auth-frame.component';
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent], imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<app-auth-frame title="LOG IN" subtitle="Your mailbox address and password"> <app-auth-frame title="LOG IN" [subtitle]="sso() ? 'With your Jambotron account' : 'Your mailbox address and password'">
<form (ngSubmit)="submit()" class="space-y-4"> @if (sso()) {
<a href="/api/oauth/start" class="cyber-btn w-full py-3 rounded-xl font-bold text-sm mb-3">
<mat-icon>login</mat-icon> SIGN IN WITH JAMBOTRON
</a>
<p class="text-xs text-slate-400 font-rajdhani text-center mb-4">The same login as on jambotron.com — Google, GitHub or password. For Jambotron staff.</p>
@if (ssoError()) {
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2 mb-4" role="alert">
<mat-icon class="!text-lg">error</mat-icon>{{ ssoError() }}
</p>
}
<button type="button" class="text-xs text-slate-500 hover:text-cyan-300 font-mono w-full text-center"
[attr.aria-expanded]="adminLogin()" (click)="adminLogin.set(!adminLogin())">
{{ adminLogin() ? 'Hide administrator login' : 'Administrator login (password)' }}
</button>
}
@if (!sso() || adminLogin()) {
<form (ngSubmit)="submit()" class="space-y-4" [class.mt-4]="sso()">
<div> <div>
<label class="synth-label" for="email">Email</label> <label class="synth-label" for="email">Email</label>
<input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required <input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required
@@ -39,6 +55,7 @@ import { AuthFrameComponent } from './auth-frame.component';
<mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }} <mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }}
</button> </button>
</form> </form>
}
@if (registration()) { @if (registration()) {
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani"> <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> No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a>
@@ -57,9 +74,15 @@ export class LoginPage {
readonly busy = signal(false); readonly busy = signal(false);
readonly error = signal(''); readonly error = signal('');
readonly registration = signal(false); readonly registration = signal(false);
readonly sso = signal(false);
readonly adminLogin = signal(false);
readonly ssoError = signal(inject(ActivatedRoute).snapshot.queryParamMap.get('sso_error') ?? '');
constructor() { constructor() {
inject(ApiService).config().subscribe({ next: c => this.registration.set(c.registrationEnabled), error: () => {} }); inject(ApiService).config().subscribe({
next: c => { this.registration.set(c.registrationEnabled); this.sso.set(!!c.ssoEnabled); },
error: () => {}
});
} }
submit(): void { submit(): void {
+11 -2
View File
@@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
</div> </div>
<div> <div>
<label class="synth-label" for="password">Password (8+ characters)</label> <label class="synth-label" for="password">Password (8+ characters)</label>
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()" <div class="relative"><input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password()"
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password"> (ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-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> </div>
<div> <div>
<label class="synth-label" for="password2">Password again</label> <label class="synth-label" for="password2">Password again</label>
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()" <div class="relative"><input id="password2" name="password2" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password2()"
(ngModelChange)="password2.set($event)" required autocomplete="new-password"> (ngModelChange)="password2.set($event)" required autocomplete="new-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> </div>
@if (error()) { @if (error()) {
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert"> <p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
@@ -79,6 +87,7 @@ export class RegisterPage {
readonly loaded = signal(false); readonly loaded = signal(false);
readonly enabled = signal(false); readonly enabled = signal(false);
readonly show = signal(false);
readonly domains = signal<string[]>([]); readonly domains = signal<string[]>([]);
readonly domain = signal(''); readonly domain = signal('');
readonly local = signal(''); readonly local = signal('');
+26
View File
@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../../core/auth.service';
/** «Sign in with Jambotron» lands here: /sso#token=…&email=… → session → mailbox. */
@Component({
selector: 'app-sso',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p class="p-8 text-center text-slate-300 font-mono" role="status">Signing in…</p>`
})
export class SsoPage {
constructor() {
const auth = inject(AuthService);
const router = inject(Router);
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
const token = params.get('token');
const email = params.get('email');
history.replaceState(null, '', '/sso'); // the token does not stay in the address bar / history
if (token && email) {
auth.signIn({ token, email });
router.navigate(['/']);
} else {
router.navigate(['/login'], { queryParams: { sso_error: 'Sign-in did not finish — try again' } });
}
}
}
@@ -141,6 +141,8 @@ export class ComposePage {
readonly body = signal(''); readonly body = signal('');
readonly showCc = signal(false); readonly showCc = signal(false);
readonly draftId = signal<number | null>(null); readonly draftId = signal<number | null>(null);
/** the message being answered: marked answered (and by whom) when this one is sent */
private replyToId: number | null = null;
readonly attachments = signal<Attachment[]>([]); readonly attachments = signal<Attachment[]>([]);
readonly dirty = signal(false); readonly dirty = signal(false);
readonly saving = signal(false); readonly saving = signal(false);
@@ -207,6 +209,7 @@ export class ComposePage {
} }
private fromReply(m: Message, all: boolean): void { private fromReply(m: Message, all: boolean): void {
this.replyToId = m.id;
const me = (this.state.profile()?.email ?? '').toLowerCase(); const me = (this.state.profile()?.email ?? '').toLowerCase();
this.to.set(m.senderEmail); this.to.set(m.senderEmail);
if (all) { if (all) {
@@ -306,7 +309,7 @@ export class ComposePage {
const go = () => { const go = () => {
this.sending.set(true); this.sending.set(true);
this.error.set(''); this.error.set('');
this.api.send(this.current(), this.draftId()).subscribe({ this.api.send(this.current(), this.draftId(), this.replyToId).subscribe({
next: () => { next: () => {
this.done = true; this.done = true;
this.toasts.ok('Message sent'); this.toasts.ok('Message sent');
+1 -1
View File
@@ -67,7 +67,7 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</ng-template> </ng-template>
<ng-template jtGridCell="subject" let-row> <ng-template jtGridCell="subject" let-row>
<a [routerLink]="link(row)" class="block max-w-[560px] group"> <a [routerLink]="link(row)" class="block max-w-[480px] group">
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read"> <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> } @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)' }} {{ row.subject || '(no subject)' }}
@@ -73,6 +73,10 @@ import { ToastService } from '../../ui/toast.service';
} }
<dt class="text-slate-500 font-mono text-xs pt-0.5">Date</dt> <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> <dd class="text-slate-400 font-mono text-xs pt-0.5">{{ m.receivedAt | date: 'EEEE, d MMMM y, HH:mm' }}</dd>
@if (m.answeredBy) {
<dt class="text-slate-500 font-mono text-xs pt-0.5">Answered</dt>
<dd class="text-emerald-300 font-mono text-xs pt-0.5 flex items-center gap-1"><mat-icon class="!text-sm">reply</mat-icon> by {{ m.answeredBy }}</dd>
}
</dl> </dl>
</div> </div>
@@ -0,0 +1,89 @@
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 { ApiService } from '../../core/api.service';
import { AppPassword, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** User panel → APP PASSWORDS: Thunderbird, phone … — SMTP / IMAP only; the web client is «Sign in with Jambotron». */
@Component({
selector: 'app-app-passwords-section',
imports: [DatePipe, FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block' },
template: `
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="app-pw-title">
<h2 id="app-pw-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-1 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">key</mat-icon> APP PASSWORDS
</h2>
<p class="text-xs text-slate-400 font-mono mb-3">For a mail program (Thunderbird, phone): login — your mailbox address,
password — an app password. IMAP and SMTP on this server. Not for the web.</p>
@if (secret(); as s) {
<div class="p-3 mb-3 rounded-lg border border-amber-400/50 bg-amber-400/10" role="status">
<div class="text-xs text-amber-200 mb-1">«{{ s.name }}» — copy it now, it is shown only once:</div>
<div class="flex items-center gap-2">
<code class="font-mono text-base text-amber-100 select-all break-all">{{ s.password }}</code>
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
</div>
</div>
}
<ul class="space-y-1 mb-3">
@for (p of list(); track p.id) {
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15 text-sm">
<mat-icon class="!text-lg text-slate-400">devices</mat-icon>
<span class="flex-grow truncate text-slate-200">{{ p.name }}</span>
<span class="text-[11px] font-mono text-slate-500">{{ p.lastUsedAt ? 'used ' + (p.lastUsedAt | date:'d MMM HH:mm') : 'not used yet' }}</span>
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(p)" [attr.aria-label]="'Revoke ' + p.name"
title="Revoke — the program stops getting mail"><mat-icon>delete</mat-icon></button>
</li>
} @empty {
<li class="text-xs text-slate-500 font-mono px-1">No app passwords yet</li>
}
</ul>
<form class="flex items-end gap-2" (ngSubmit)="create()">
<div class="flex-grow"><label class="synth-label" for="app-pw-name">Program</label>
<input id="app-pw-name" name="name" class="synth-input" [(ngModel)]="name" placeholder="Thunderbird at home" autocomplete="off"></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>add</mat-icon> NEW</button>
</form>
</section>
`
})
export class AppPasswordsSection {
private api = inject(ApiService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
readonly list = signal<AppPassword[]>([]);
readonly secret = signal<{ name: string; password: string } | null>(null);
name = '';
constructor() {
this.load();
}
load(): void {
this.api.appPasswords().subscribe({ next: l => this.list.set(l), error: () => {} });
}
create(): void {
this.api.createAppPassword(this.name.trim()).subscribe({
next: r => { this.secret.set({ name: r.appPassword.name, password: r.password }); this.name = ''; this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
remove(p: AppPassword): void {
this.dialogs.confirm(`Revoke «${p.name}»?`, 'That program stops getting and sending mail until it gets a new app password.', 'Revoke', true)
.subscribe(ok => {
if (ok === null) return;
this.api.deleteAppPassword(p.id).subscribe({ next: () => this.load(), error: e => this.toasts.error(errorText(e)) });
});
}
copy(text: string): void {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
}
}
@@ -0,0 +1,154 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { MailList, Mailbox, errorText } from '../../core/models';
import { ToastService } from '../../ui/toast.service';
/**
* Settings → LISTS (administrators): alerts-prod@ → a copy to every member, into the list's folder.
* Lists kept by Jambotron are edited here too, but Jambotron puts its own member set back on its next sync.
*/
@Component({
selector: 'app-lists-section',
imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block xl:col-span-2' },
template: `
<section class="glass-panel static p-5 md:p-6" aria-labelledby="lists-title">
<h2 id="lists-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-2 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">forward_to_inbox</mat-icon> LISTS
</h2>
<p class="text-xs text-slate-400 mb-4">
Mail to a list goes to every member — each gets their own copy (own read/unread) in the list's folder.
<span class="font-mono">list+tag&#64;</span> puts it into folder «tag» instead. Closed list: only logged-in senders (Kuma, Jambotron).
</p>
<form (ngSubmit)="save()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr] gap-3 mb-3">
<div><label class="synth-label" for="ls-address">Address</label>
<div class="flex items-stretch gap-2">
<input id="ls-address" name="address" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
placeholder="alerts-prod" autocomplete="off" required [readonly]="editing()">
<span class="flex items-center text-cyan-300 font-mono">&#64;</span>
<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 whitespace-nowrap">{{ domain() }}</span>
</div></div>
<div><label class="synth-label" for="ls-folder">Folder (empty — INBOX)</label>
<input id="ls-folder" name="folder" class="synth-input" [(ngModel)]="folder" maxlength="50" placeholder="prod" autocomplete="off"></div>
<div><label class="synth-label" for="ls-about">Description</label>
<input id="ls-about" name="about" class="synth-input" [(ngModel)]="description" maxlength="255" autocomplete="off"></div>
<fieldset class="md:col-span-3">
<legend class="synth-label">Members</legend>
<div class="flex flex-wrap gap-x-4 gap-y-1">
@for (m of mailboxes(); track m.id) {
<label class="flex items-center gap-1.5 text-sm font-mono" [class.text-slate-500]="!m.active">
<input type="checkbox" [checked]="members().has(m.email)" (change)="flip(m.email)"> {{ m.email }}
</label>
}
</div>
</fieldset>
<div class="md:col-span-3 flex flex-wrap items-center gap-3">
<label class="flex items-center gap-1.5 text-sm"><input type="checkbox" name="open" [(ngModel)]="openToOutside"> open to anyone (not only logged-in senders)</label>
<span class="ml-auto"></span>
@if (editing()) { <button type="button" class="ghost-btn !py-1" (click)="reset()"><mat-icon>close</mat-icon> CANCEL</button> }
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()">
<mat-icon>{{ editing() ? 'save' : 'add' }}</mat-icon> {{ editing() ? 'SAVE' : 'CREATE' }}</button>
</div>
</form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
<ul class="divide-y divide-cyan-500/10">
@for (l of lists(); track l.id) {
<li class="py-2">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="font-mono text-sm text-cyan-300">{{ l.address }}</span>
<span class="text-xs font-mono text-slate-400">→ {{ l.folder || 'INBOX' }}</span>
@if (l.openToOutside) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300">OPEN</span> }
<span class="text-sm text-slate-400">{{ l.description }}</span>
<span class="ml-auto"></span>
<button type="button" class="ghost-btn !py-1" (click)="edit(l)" [attr.aria-label]="'Edit ' + l.address"><mat-icon>edit</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="remove(l)" [attr.aria-label]="'Delete ' + l.address"><mat-icon>delete</mat-icon></button>
</div>
<div class="text-xs font-mono text-slate-500 mt-0.5">{{ l.members.length ? l.members.join(', ') : 'no members — mail to it is dropped' }}</div>
</li>
} @empty {
<li class="py-2 text-sm text-slate-500">No lists yet.</li>
}
</ul>
</section>
`
})
export class ListsSection {
private api = inject(ApiService);
private toasts = inject(ToastService);
readonly lists = signal<MailList[]>([]);
readonly mailboxes = signal<Mailbox[]>([]);
readonly members = signal<Set<string>>(new Set());
readonly local = signal('');
readonly domains = signal<string[]>([]);
readonly domain = computed(() => this.domains()[0] ?? '');
readonly editing = signal(false);
readonly error = signal('');
readonly busy = signal(false);
folder = '';
description = '';
openToOutside = false;
constructor() {
this.api.config().subscribe({ next: c => this.domains.set(c.domains), error: () => {} });
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: () => {} });
this.load();
}
load(): void {
this.api.mailLists().subscribe({ next: list => this.lists.set(list), error: e => this.error.set(errorText(e)) });
}
flip(email: string): void {
const next = new Set(this.members());
if (next.has(email)) { next.delete(email); } else { next.add(email); }
this.members.set(next);
}
edit(l: MailList): void {
this.local.set(l.address.split('@')[0]);
this.domains.set([l.address.split('@')[1], ...this.domains().filter(d => d !== l.address.split('@')[1])]);
this.folder = l.folder ?? '';
this.description = l.description ?? '';
this.openToOutside = l.openToOutside;
this.members.set(new Set(l.members));
this.editing.set(true);
this.error.set('');
}
reset(): void {
this.local.set('');
this.folder = this.description = '';
this.openToOutside = false;
this.members.set(new Set());
this.editing.set(false);
}
save(): void {
const local = this.local().replace(/@.*$/, '');
if (!local) { this.error.set('Enter an address'); return; }
const address = local + '@' + this.domain();
this.error.set('');
this.busy.set(true);
this.api.saveMailList(address, {
folder: this.folder.trim(), description: this.description.trim(), openToOutside: this.openToOutside,
members: [...this.members()]
}).subscribe({
next: saved => { this.busy.set(false); this.toasts.ok('List ' + saved.address + ' saved'); this.reset(); this.load(); },
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
});
}
remove(l: MailList): void {
if (!confirm('Delete list ' + l.address + '? Mail to it will be refused.')) return;
this.api.deleteMailList(l.address).subscribe({
next: () => { this.toasts.ok('List ' + l.address + ' deleted'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
}
@@ -5,26 +5,48 @@ import { ApiService } from '../../core/api.service';
import { MailStateService } from '../../core/mail-state.service'; import { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText, formatBytes } from '../../core/models'; import { Mailbox, errorText, formatBytes } from '../../core/models';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { AuthService } from '../../core/auth.service';
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */ /** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
@Component({ @Component({
selector: 'app-mailboxes-section', selector: 'app-mailboxes-section',
imports: [FormsModule, MatIconModule], imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
// ячейка сетки настроек — сам элемент компонента: на две колонки
host: { class: 'block xl:col-span-2' },
template: ` template: `
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title"> <section class="glass-panel static p-5 md:p-6" aria-labelledby="mailboxes-title">
<h2 id="mailboxes-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"> <h2 id="mailboxes-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">group</mat-icon> MAILBOXES <mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
</h2> </h2>
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4"> <form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
<div><label class="synth-label" for="mb-email">Address</label> <div><label class="synth-label" for="mb-email">Address</label>
<input id="mb-email" name="email" class="synth-input" [(ngModel)]="email" [placeholder]="'no-reply@' + domain()" autocomplete="off" required></div> <div class="flex items-stretch gap-2">
<input id="mb-email" name="email" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
placeholder="no-reply" autocomplete="off" required>
<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 whitespace-nowrap">{{ domain() }}</span>
}
</div></div>
<div><label class="synth-label" for="mb-name">Name</label> <div><label class="synth-label" for="mb-name">Name</label>
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div> <input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label> <div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
<input id="mb-password" name="password" type="password" class="synth-input" [(ngModel)]="password" autocomplete="new-password"></div> <div class="relative"><input id="mb-password" name="password" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="password" autocomplete="new-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]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button> <button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
<label class="md:col-span-4 flex items-center gap-1.5 text-sm text-slate-300">
<input type="checkbox" name="shared" [(ngModel)]="shared">
shared mailbox (support&#64;, billing&#64;): no password of its own — members open it from their mailbox, read/unread is common
</label>
</form> </form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> } @if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
@@ -44,13 +66,59 @@ import { ToastService } from '../../ui/toast.service';
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2"> <li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span> <span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<span class="text-sm text-slate-400">{{ m.displayName }}</span> <span class="text-sm text-slate-400">{{ m.displayName }}</span>
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> } @if (m.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
@if (m.system) { <span class="text-[10px] font-mono px-1.5 rounded border border-violet-400/50 text-violet-300"
title="System mailbox: no web login; the built-in administrators open it, programs use its password">SYSTEM</span> }
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</span> }
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span> <span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
<mat-icon>mark_email_read</mat-icon> OPEN</button>
}
<button type="button" class="ghost-btn !py-1" (click)="editMembers(m)" [attr.aria-expanded]="membersOf() === m.id"
[attr.aria-label]="'Who may open ' + m.email" title="Who may open this mailbox"><mat-icon>group</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="trap(m)" [class.text-amber-300]="m.trap"
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
@if (!m.shared && !m.admin) {
<button type="button" class="ghost-btn !py-1" (click)="system(m)" [class.text-violet-300]="m.system"
[attr.aria-label]="(m.system ? 'Not system: ' : 'Make system: ') + m.email"
[title]="m.system ? 'System mailbox — click to make it ordinary' : 'Make it a system mailbox (no-reply@, monitor@ …)'">
<mat-icon>dns</mat-icon></button>
}
@if (!m.shared) {
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button> <button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
}
@if (m.email !== me()) { @if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)" <button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email"> [attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button> <mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(m)"
[attr.aria-label]="'Delete ' + m.email" title="Delete the mailbox with all its mail">
<mat-icon>delete</mat-icon></button>
}
@if (membersOf() === m.id) {
<div class="basis-full mt-1 p-2 rounded-lg border border-cyan-500/20">
<div class="text-xs text-slate-400 mb-1">Who may open <span class="font-mono text-cyan-300">{{ m.email }}</span> from their own mailbox:</div>
<div class="flex flex-wrap gap-x-4 gap-y-1">
@for (o of mailboxes(); track o.id) {
@if (o.id !== m.id && !o.shared) {
<label class="flex items-center gap-1.5 text-sm font-mono">
<input type="checkbox" [checked]="picked().has(o.email)" (change)="pick(o.email)"> {{ o.email }}
</label>
}
}
</div>
<div class="flex gap-2 mt-2">
<button type="button" class="cyber-btn px-3 py-1 rounded-lg text-xs font-bold" (click)="saveMembers(m)"><mat-icon>save</mat-icon> SAVE</button>
<button type="button" class="ghost-btn !py-1" (click)="membersOf.set(null)">CANCEL</button>
</div>
</div>
} }
</li> </li>
} }
@@ -62,19 +130,31 @@ export class MailboxesSection {
private api = inject(ApiService); private api = inject(ApiService);
private state = inject(MailStateService); private state = inject(MailStateService);
private toasts = inject(ToastService); private toasts = inject(ToastService);
private auth = inject(AuthService);
readonly mailboxes = signal<Mailbox[]>([]); readonly mailboxes = signal<Mailbox[]>([]);
readonly secret = signal<{ email: string; password: string } | null>(null); readonly secret = signal<{ email: string; password: string } | null>(null);
readonly error = signal(''); readonly error = signal('');
readonly busy = signal(false); readonly busy = signal(false);
readonly me = computed(() => this.state.profile()?.email ?? ''); readonly me = computed(() => this.state.profile()?.email ?? '');
readonly domain = computed(() => this.me().split('@')[1] ?? 'example.com'); readonly showPw = signal(false);
readonly local = signal('');
email = ''; // Домены сервера (/api/config); пока не пришли — домен своего ящика
readonly domains = signal<string[]>([]);
readonly domain = signal('');
name = ''; name = '';
password = ''; password = '';
shared = false;
/** mailbox whose members are being edited */
readonly membersOf = signal<number | null>(null);
readonly picked = signal<Set<string>>(new Set());
constructor() { constructor() {
this.domain.set(this.me().split('@')[1] ?? '');
this.api.config().subscribe({
next: c => { this.domains.set(c.domains); if (!c.domains.includes(this.domain())) { this.domain.set(c.domains[0] ?? ''); } },
error: () => {}
});
this.load(); this.load();
} }
@@ -87,14 +167,17 @@ export class MailboxesSection {
} }
create(): void { create(): void {
const email = this.email.trim(); const local = this.local().replace(/@.*$/, '');
if (!email) { this.error.set('Enter an address'); return; } if (!local) { this.error.set('Enter an address'); return; }
const email = local + '@' + this.domain();
this.error.set(''); this.error.set('');
this.busy.set(true); this.busy.set(true);
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({ this.api.createMailbox(email, this.name.trim(), this.password, this.shared).subscribe({
next: created => { next: created => {
this.busy.set(false); this.busy.set(false);
this.email = this.name = this.password = ''; this.local.set('');
this.name = this.password = '';
this.shared = false;
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); } if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created'); this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
this.load(); this.load();
@@ -111,6 +194,52 @@ export class MailboxesSection {
}); });
} }
/** Administrator reads this mailbox: its session; the banner on top leads back. Full reload — fresh folders. */
open(m: Mailbox): void {
this.api.openMailbox(m.id).subscribe({
next: session => { this.auth.openAs(session); location.assign('/'); },
error: e => this.toasts.error(errorText(e))
});
}
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
system(m: Mailbox): void {
const on = !m.system;
if (!confirm(on ? `${m.email} becomes a system mailbox: no web login, the built-in administrators open it. Programs keep its password.`
: `${m.email} becomes an ordinary mailbox again.`)) return;
this.api.setMailboxSystem(m.id, on).subscribe({
next: () => { this.toasts.ok(m.email + (on ? ' is a system mailbox' : ' is ordinary')); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
trap(m: Mailbox): void {
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
this.api.setMailboxTrap(m.id, !m.trap).subscribe({
next: () => { this.toasts.ok('Trap ' + (m.trap ? 'off' : 'on') + ' for ' + m.email); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
editMembers(m: Mailbox): void {
if (this.membersOf() === m.id) { this.membersOf.set(null); return; }
this.picked.set(new Set(m.members));
this.membersOf.set(m.id);
}
pick(email: string): void {
const next = new Set(this.picked());
if (next.has(email)) { next.delete(email); } else { next.add(email); }
this.picked.set(next);
}
saveMembers(m: Mailbox): void {
this.api.setMailboxMembers(m.id, [...this.picked()]).subscribe({
next: () => { this.toasts.ok('Members of ' + m.email + ' saved'); this.membersOf.set(null); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
toggle(m: Mailbox): void { toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({ this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(), next: () => this.load(),
@@ -118,6 +247,14 @@ export class MailboxesSection {
}); });
} }
remove(m: Mailbox): void {
if (!confirm('Delete ' + m.email + ' with ALL its mail? This can not be undone.')) return;
this.api.deleteMailbox(m.id).subscribe({
next: () => { this.toasts.ok(m.email + ' deleted'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
copy(text: string): void { copy(text: string): void {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand')); navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
} }
@@ -0,0 +1,95 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service';
import { MailStateService } from '../../core/mail-state.service';
import { MailboxEntry, errorText } from '../../core/models';
import { DialogService } from '../../ui/dialog.service';
import { ToastService } from '../../ui/toast.service';
/** Settings → MY MAILBOXES (everyone): mailboxes in the sidebar combobox — add one by its password, remove an added one. */
@Component({
selector: 'app-my-mailboxes-section',
imports: [FormsModule, MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'block' },
template: `
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="my-boxes-title">
<h2 id="my-boxes-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">alternate_email</mat-icon> MY MAILBOXES
</h2>
<ul class="space-y-1 mb-4" aria-label="Your mailboxes">
@for (b of state.boxes(); track b.email) {
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15">
<mat-icon class="!text-lg text-slate-400 shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate font-mono text-sm text-slate-200">{{ b.email }}</span>
<span class="text-[11px] font-mono text-slate-500">{{ b.kind === 'OWN' ? 'own' : b.shared ? 'shared' : 'added' }}</span>
@if (b.kind !== 'OWN') {
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="leave(b)"
[attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'" title="Remove from my mailboxes">
<mat-icon>close</mat-icon></button>
}
</li>
}
</ul>
<form class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-end" (ngSubmit)="attach()" aria-label="Add a mailbox">
<div><label class="synth-label" for="att-email">Mailbox</label>
<input id="att-email" name="attEmail" class="synth-input" [(ngModel)]="email" autocomplete="off" placeholder="name&#64;domain"></div>
<div><label class="synth-label" for="att-pw">Its password</label>
<input id="att-pw" name="attPw" type="password" class="synth-input" [(ngModel)]="password" autocomplete="off"></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
</form>
</section>
`
})
export class MyMailboxesSection {
readonly state = inject(MailStateService);
private api = inject(ApiService);
private auth = inject(AuthService);
private dialogs = inject(DialogService);
private toasts = inject(ToastService);
email = '';
password = '';
constructor() {
this.state.refreshBoxes();
}
attach(): void {
const email = this.email.trim().toLowerCase();
if (!email || !this.password) { this.toasts.error('Mailbox and its password'); return; }
this.api.attachMailbox(email, this.password).subscribe({
next: b => {
this.email = this.password = '';
this.toasts.ok(b.email + ' added — pick it in the MAILBOX list on the left');
this.state.refreshBoxes();
},
error: e => this.toasts.error(errorText(e))
});
}
leave(b: MailboxEntry): void {
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
? 'It disappears from your list. An administrator can give it back.'
: 'It disappears from your list. Add it again with its password.', 'Remove', true).subscribe(ok => {
if (ok === null) return;
this.api.leaveMailbox(b.email).subscribe({
next: () => {
this.toasts.ok(b.email + ' removed');
if (b.email === this.auth.email()) {
// removed the one we are in — back to the own mailbox
this.api.mailboxSession(this.state.boxes().find(x => x.kind === 'OWN')?.email ?? '').subscribe({
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
error: () => location.assign('/')
});
return;
}
this.state.refreshBoxes();
},
error: e => this.toasts.error(errorText(e))
});
});
}
}
@@ -7,12 +7,13 @@ import { MailStateService } from '../../core/mail-state.service';
import { errorText, formatBytes } from '../../core/models'; import { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service'; import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section'; import { MyMailboxesSection } from './my-mailboxes.section';
import { AppPasswordsSection } from './app-passwords.section';
/** Profile name, password, storage, look. */ /** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection], imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection, AppPasswordsSection],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -42,11 +43,23 @@ import { MailboxesSection } from './mailboxes.section';
<form (ngSubmit)="changePassword()" class="space-y-3"> <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"> <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> <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 class="relative"><input id="pw-current" name="current" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="current" 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]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label> <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 class="relative"><input id="pw-new" name="next" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="next" autocomplete="new-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]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<div><label class="synth-label" for="pw-again">New password again</label> <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> <div class="relative"><input id="pw-again" name="again" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="again" autocomplete="new-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]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> } @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> <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> </form>
@@ -80,7 +93,7 @@ import { MailboxesSection } from './mailboxes.section';
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@for (t of themes; track t.value) { @for (t of themes; track t.value) {
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === 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" [class.nav-active]="theme.theme() === t.value"
(click)="theme.theme.set(t.value)">{{ t.label }}</button> (click)="theme.theme.set(t.value)">{{ t.label }}</button>
} }
</div> </div>
@@ -99,9 +112,9 @@ import { MailboxesSection } from './mailboxes.section';
</div> </div>
</section> </section>
@if (state.profile()?.admin) { <app-my-mailboxes-section />
<app-mailboxes-section /> <app-app-passwords-section />
}
</div> </div>
` `
}) })
@@ -117,6 +130,7 @@ export class SettingsPage {
next = ''; next = '';
again = ''; again = '';
readonly passwordError = signal(''); readonly passwordError = signal('');
readonly showPw = signal(false);
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes)); readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes)); readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
@@ -8,7 +8,7 @@ import { filter } from 'rxjs';
import { ApiService } from '../../core/api.service'; import { ApiService } from '../../core/api.service';
import { AuthService } from '../../core/auth.service'; import { AuthService } from '../../core/auth.service';
import { MailStateService } from '../../core/mail-state.service'; import { MailStateService } from '../../core/mail-state.service';
import { Folder, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models'; import { Folder, MailboxEntry, errorText, folderIcon, folderLabel, formatBytes } from '../../core/models';
import { BackgroundComponent } from '../../ui/background.component'; import { BackgroundComponent } from '../../ui/background.component';
import { DialogService } from '../../ui/dialog.service'; import { DialogService } from '../../ui/dialog.service';
import { THEMES, ThemeService } from '../../ui/theme.service'; import { THEMES, ThemeService } from '../../ui/theme.service';
@@ -69,9 +69,28 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</header> </header>
@if (auth.openedBy(); as admin) {
<div class="bg-pink-600/90 text-white text-sm font-rajdhani px-4 py-2 flex flex-wrap items-center justify-center gap-3" role="status">
<mat-icon class="!text-base">visibility</mat-icon>
<span>You are in mailbox <b class="font-mono">{{ auth.email() }}</b> as administrator</span>
<button type="button" class="ghost-btn !py-1 !border-white !text-white" (click)="backToAdmin()">
<mat-icon>arrow_back</mat-icon> Back to {{ admin }}</button>
</div>
}
<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"> <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()" <nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders"> aria-label="Mail folders">
<label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
<select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
(ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
@for (b of state.boxes(); track b.email) {
<option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
} @empty {
<option [value]="auth.email()">{{ auth.email() }}</option>
}
</select>
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)"> <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 <mat-icon>edit</mat-icon> COMPOSE
</a> </a>
@@ -79,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1"> <ul class="space-y-1">
@for (folder of systemFolders(); track folder.id) { @for (folder of systemFolders(); track folder.id) {
<li> <li>
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40" <a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
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" 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)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon> <mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
@@ -101,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
<ul class="space-y-1"> <ul class="space-y-1">
@for (folder of ownFolders(); track folder.id) { @for (folder of ownFolders(); track folder.id) {
<li class="flex items-center gap-1"> <li class="flex items-center gap-1">
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40" <a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
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" 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)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">folder</mat-icon> <mat-icon class="!text-xl">folder</mat-icon>
@@ -119,12 +138,19 @@ import { ToastService } from '../../ui/toast.service';
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1"> <div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
@for (link of links; track link.path) { @for (link of links; track link.path) {
<a [routerLink]="link.path" routerLinkActive="!text-pink-300" <a [routerLink]="link.path" routerLinkActive="nav-active-text"
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold" 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)"> (click)="menuOpen.set(false)">
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }} <mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
</a> </a>
} }
@if (state.profile()?.admin) {
<a routerLink="/admin" routerLinkActive="nav-active-text"
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">admin_panel_settings</mat-icon>Admin panel
</a>
}
</div> </div>
@if (state.profile(); as p) { @if (state.profile(); as p) {
@@ -161,7 +187,7 @@ export class ShellComponent {
readonly links = [ readonly links = [
{ path: '/contacts', label: 'Contacts', icon: 'contacts' }, { path: '/contacts', label: 'Contacts', icon: 'contacts' },
{ path: '/blocked', label: 'Blocked senders', icon: 'block' }, { path: '/blocked', label: 'Blocked senders', icon: 'block' },
{ path: '/settings', label: 'Settings', icon: 'settings' } { path: '/settings', label: 'User panel', icon: 'person' }
]; ];
readonly folderActions: RowAction[] = [ readonly folderActions: RowAction[] = [
@@ -186,7 +212,8 @@ export class ShellComponent {
constructor() { constructor() {
this.state.refreshFolders(); this.state.refreshFolders();
this.state.refreshProfile(); this.state.refreshProfile();
const timer = setInterval(() => this.state.refreshFolders(), 30000); this.state.refreshBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 30000);
inject(DestroyRef).onDestroy(() => clearInterval(timer)); inject(DestroyRef).onDestroy(() => clearInterval(timer));
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => { this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const q = this.router.parseUrl(this.router.url).queryParams['q']; const q = this.router.parseUrl(this.router.url).queryParams['q'];
@@ -247,6 +274,25 @@ export class ShellComponent {
} }
} }
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */
switchTo(b: MailboxEntry): void {
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
this.api.mailboxSession(b.email).subscribe({
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
error: e => this.toasts.error(errorText(e))
});
}
pick(email: string): void {
const b = this.state.boxes().find(x => x.email === email);
if (b) this.switchTo(b);
}
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');
}
logout(): void { logout(): void {
this.state.reset(); this.state.reset();
this.auth.logout(); this.auth.logout();
+14
View File
@@ -300,6 +300,20 @@ body {
} }
.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); } .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-input::placeholder { color: #64748b; }
/* Selected item of the sidebar (folder, link) — in the colour of the theme, not a fixed pink */
.nav-active {
background-color: color-mix(in srgb, var(--primary-color) 18%, transparent) !important;
border-color: color-mix(in srgb, var(--primary-color) 45%, transparent) !important;
color: var(--primary-color) !important;
}
.nav-active-text { color: var(--primary-color) !important; }
.role-admin { color: var(--primary-color); border-color: color-mix(in srgb, var(--primary-color) 50%, transparent); }
/* Selects: the opened list is drawn by the browser — without this it is white (light OS theme) with our light text */
select { color-scheme: dark; }
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }
select option:checked { background-color: var(--primary-color); color: #020617; }
.high-contrast select option { background-color: #000000; color: #ffffff; }
.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; } .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; }
.ghost-btn { .ghost-btn {