Compare commits

...
52 Commits
Author SHA1 Message Date
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
Liosha84andClaude Opus 5.5 f9b929ac7a Веб-почта по HTTPS на том же адресе: https://mail.jambocheck.com (порт 443)
continuous-integration/drone/pr Build is passing
HTTPS_PORT — второй Netty-сервер с тем же HttpHandler и тем же сертификатом, что SMTP/IMAP
(Let's Encrypt от acme, перечитывается при продлении). HSTS добавляет Spring Security.
docker-compose.public.yml: 443 → 8443. HTTP 8080 остаётся для прокси внутри сети.
Тесты: HttpsConfigTest, всего 74; живой запуск: /api/config по HTTPS, HSTS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:18:42 +01:00
Liosha84 4ad2e44e9b Merge pull request 'Ящики из веб-почты: создать, новый пароль, выключить (для администратора)' (#16) from feat/admin-mailboxes into master
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2026-11-07 01:49:57 +01:00
Liosha84andClaude Opus 5.5 2149c29a32 Ящики из веб-почты: администратор создаёт no-reply@, monitor@, support@, задаёт новый пароль, выключает
continuous-integration/drone/pr Build is passing
Регистрация остаётся закрытой. Администраторы — MAIL_ADMINS (по умолчанию первый ящик);
роль не в базе, укравший ящик себя админом не сделает. API /api/admin/mailboxes,
сгенерированный пароль — один раз в ответе, в лог не пишется. Настройки → MAILBOXES
(видно только администратору). Тесты: MailboxAdminControllerTest (5), всего 73.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:23:48 +01:00
Liosha84 048d6b3679 Merge pull request 'docker-compose.public.yml: значения по умолчанию для jambocheck.com (деплой без .env)' (#15) from fix/public-compose-defaults into master
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2026-11-06 20:20:21 +01:00
Liosha84andClaude Opus 5.5 2a422f638f docker-compose.public.yml: наши значения по умолчанию (mail.jambocheck.com) — Jambotron деплоит без .env
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:19:32 +01:00
Liosha84 0eb5348e4b Merge pull request 'Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику' (#14) from feat/login-guard into master
continuous-integration/drone/push Build is passing
Reviewed-on: #14
2026-11-06 18:44:15 +01:00
Liosha84andClaude Opus 5.5 b19e9682a8 Защита от перебора паролей: SMTP, IMAP, веб — лимит по IP и по ящику
continuous-integration/drone/pr Build is passing
Один LoginGuard на все проверки пароля (SMTP AUTH, IMAP LOGIN/AUTHENTICATE,
вход в веб, смена пароля): 10 неверных с IP за 15 мин → блок 15 мин, дальше
вдвое дольше до суток; 20 неверных на ящик с разных IP → ящик закрыт, кроме
IP, с которых в него уже входили. Во время блока пароль не проверяется
(SMTP 421, IMAP BYE + NO [UNAVAILABLE], веб 429 + Retry-After); неверный
пароль — ответ через 1 с; 3 неверных в соединении — обрыв.

X-Forwarded-For веб-входа — только от прокси в частной сети, иначе
заголовок отбрасывается (подменой IP лимит не обойти).

Попутно: IMAP терял тегированный ответ после "* BYE" (LOGOUT, обрыв после
неудачных входов) — takeUntil теперь после тегированной строки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:32:33 +01:00
Liosha84 8f3ad77b69 Merge pull request 'Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt) + перечитка сертификата' (#13) from feat/public-mail into master
continuous-integration/drone/push Build is passing
Reviewed-on: #13
2026-11-06 13:12:10 +01:00
Liosha84andClaude Opus 5.5 711807847e Настоящая почта: docker-compose.public.yml (MX, DKIM, Let's Encrypt через lego), сертификат перечитывается без перезапуска
continuous-integration/drone/pr Build is passing
- ReloadingKeyManager: сертификат и ключ из PEM (PKCS#8, RSA, EC/SEC1 как у lego), раз в минуту
  проверяет файлы и перечитывает их; битый файл оставляет прежний сертификат; поколения по алиасам.
- docker-compose.public.yml: порты 25/587/465/993 на публичном адресе ВМ, OUTBOUND_MODE=mx, DKIM,
  SPF/DMARC, регистрация закрыта, без ловушки; сервис acme (lego v5.5.2, HTTP-01 на :80, EC256,
  продление за 30 дней, хук кладёт пару в том tls для uid 10001).
- README: раздел «Настоящая почта», DNS-записи, перечитка сертификата.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:10:04 +01:00
Liosha84 620aae179b Merge pull request 'Docker-образ в реестре Gitea (как у htop-web) и docker-compose.image.yml' (#12) from feat/docker-image into master
continuous-integration/drone/push Build is passing
2026-11-06 11:53:33 +01:00
Liosha84andClaude Opus 5.5 a37acfaaa7 Docker-образ в реестре Gitea (Drone, как у htop-web) и docker-compose.image.yml на нём
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:58:44 +01:00
Liosha84 87c6e5325d Merge pull request 'Пароли новых ящиков — ещё и в лог (видно в Jambotron без консоли)' (#11) from feat/passwords-in-log into master
Reviewed-on: #11
2026-11-05 22:55:42 +01:00
Liosha84 152e93d997 Пароли новых ящиков — ещё и в лог: видно в Jambotron без консоли
После передеплоя (новая база) владелец видит пароли admin@ и trap@ в
проект → Сервисы → mail → Логи; файл /app/data/initial-passwords.txt остаётся.

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

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

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

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:43:49 +01:00
86 changed files with 8331 additions and 713 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.idea
.vscode
**/node_modules
**/build
**/dist
**/.gradle
**/.angular
*.log
+47
View File
@@ -0,0 +1,47 @@
# Docker image of reactive-mail-server — like htop-web: Drone builds backend/Dockerfile,
# master pushes it to the Gitea container registry of jambotron.com.
# pull request -> build only (image is not pushed)
# push master -> gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest and :<commit 8>
# Drone secrets of this repo: REGISTRY_USERNAME, REGISTRY_PASSWORD (Gitea user with write:package in jambotron_gang)
kind: pipeline
type: docker
name: build-test
trigger:
event:
- pull_request
steps:
- name: build-test
image: plugins/docker
settings:
dockerfile: backend/Dockerfile
context: .
repo: gitea.jambotron.com/jambotron_gang/reactive-mail-server
dry_run: true
---
kind: pipeline
type: docker
name: build-and-push
trigger:
event:
- push
branch:
- master
steps:
- name: build-and-push
image: plugins/docker
settings:
dockerfile: backend/Dockerfile
context: .
registry: gitea.jambotron.com
repo: gitea.jambotron.com/jambotron_gang/reactive-mail-server
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
username:
from_secret: REGISTRY_USERNAME
password:
from_secret: REGISTRY_PASSWORD
+131 -10
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`.
## Как устроено ## Как устроено
@@ -13,13 +44,15 @@
``` ```
- **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке. - **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется. Свой сертификат сервер перечитывает сам, когда файлы меняются (проверка раз в минуту): обновлённый Let's Encrypt подхватывается без перезапуска, недописанный файл не ломает TLS — остаётся прежний сертификат.
- **Не открытый релей.** - **Не открытый релей.**
- Письмо на свой домен принимается от кого угодно. - Письмо на свой домен принимается от кого угодно.
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`. - Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
- Вошедший пользователь пишет только от своего адреса, иначе `553`. - Вошедший пользователь пишет только от своего адреса, иначе `553`.
- **Маршрут** (`MailRouter`) один для SMTP и веб-почты: - **Маршрут** (`MailRouter`) один для SMTP и веб-почты:
- наш домен → наш ящик, а если ящика нет — «нет такого пользователя»; - есть такой ящик в нашем домене → в него;
- задан `MAIL_CATCH_ALL` → всё в этот ящик; - задан `MAIL_CATCH_ALL` → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
- наш домен без такого ящика → «нет такого пользователя»;
- чужой домен → очередь. - чужой домен → очередь.
- **Отправка наружу** (`OUTBOUND_MODE`): - **Отправка наружу** (`OUTBOUND_MODE`):
- `off` — ничего не уходит; - `off` — ничего не уходит;
@@ -29,6 +62,17 @@
- **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`. - **DKIM.** Подпись — `utils-mail-dkim` (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в `DKIM_KEYS_DIR`, запись для DNS печатается в лог при старте: `TXT <selector>._domainkey.<domain>`.
- **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах. - **Права.** Письма, папки и вложения отдаются только владельцу (чужие — `404`). Регистрация по умолчанию закрыта, ящики — только в своих доменах.
## IMAP
Почтовые программы (Thunderbird, почта на телефоне) работают с теми же ящиками, что и веб-почта.
- **Порт** `IMAP_PORT` (3143) со `STARTTLS`, и неявный TLS на `IMAPS_PORT` (993), если задан. Сертификат тот же, что у SMTP. Вход (`LOGIN`, `AUTHENTICATE PLAIN`) — только внутри TLS, без него сервер отвечает `LOGINDISABLED`.
- **Возможности:** IMAP4rev1, LITERAL+, IDLE (новые письма приходят сами, проверка раз в 5 с), UIDPLUS, MOVE, UNSELECT, NAMESPACE, SPECIAL-USE (Отправленные, Черновики, Корзина, Спам клиент находит сам), ID.
- **UID** выдаёт база (триггер): письмо, перенесённое в папку из веба или по IMAP, получает новый, больший UID — клиент его не пропустит. Флаги `\Seen`, `\Flagged`, `\Answered`, `\Deleted`; `\Draft` — у писем в «Черновиках».
- **Письмо целиком** (`BODY[]`) — ровно в том виде, в каком оно пришло по SMTP или через `APPEND`. Для писем, написанных в веб-почте, MIME собирается один раз и сохраняется: размер и байты не меняются между запросами.
- Каждая команда выполняется на рабочем потоке, не на сетевом. Команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается.
- Папки — плоские (без вложенных), имена — modified UTF-7, кириллица работает.
## Веб-почта ## Веб-почта
Angular 22 в стиле [synthwave-шаблона](frontend/src/assets/synthwave-design-showcase.html): стеклянные панели, неон, движущаяся сетка (выключается в настройках и при `prefers-reduced-motion`), четыре палитры и высокий контраст. Все таблицы — грид [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/jambotron-aria-grid) (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (`frontend/.npmrc`). Angular 22 в стиле [synthwave-шаблона](frontend/src/assets/synthwave-design-showcase.html): стеклянные панели, неон, движущаяся сетка (выключается в настройках и при `prefers-reduced-motion`), четыре палитры и высокий контраст. Все таблицы — грид [`@jambotron/aria-grid`](https://gitea.jambotron.com/jambotron_gang/jambotron-aria-grid) (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (`frontend/.npmrc`).
@@ -53,16 +97,41 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` | | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` |
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` | | `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный, один раз в логе | `admin@<первый домен>` | | `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 |
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
| `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_REGISTRATION` | открыть регистрацию | `false` |
| `MAIL_CATCH_ALL` | ящик-ловушка: вся почта сюда, наружу ничего | — | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
| `SMTP_PORT` | порт SMTP | `3025` | | `SMTP_PORT` | порт SMTP | `3025` |
| `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` |
| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ (PKCS#8, `RSA PRIVATE KEY` или `EC PRIVATE KEY`); перечитывается при изменении; без них — самоподписанный | — |
| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) |
| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` |
| `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` |
| `IMAP_AUTH_WITHOUT_TLS` | вход в IMAP без TLS — **только** в закрытой сети | `false` |
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` | | `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` | | `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` | | `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
| `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` | | `DKIM_DOMAIN`, `DKIM_SELECTOR`, `DKIM_KEYS_DIR` | DKIM | `localhost`, `mail`, `./dkim-keys` |
| `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` | | `SPF_ENABLED`, `DMARC_ENABLED` | проверки входящих | `true` |
| `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — | | `MAIL_CORS_ORIGINS` | другие источники для веб-клиента (только разработка) | — |
| `LOGIN_GUARD` | защита от перебора паролей (см. ниже) | `true` |
| `LOGIN_GUARD_MAX_PER_IP`, `LOGIN_GUARD_MAX_PER_ACCOUNT` | неверных паролей за окно до блокировки: с одного IP; на один ящик с любых IP | `10`, `20` |
| `LOGIN_GUARD_WINDOW`, `LOGIN_GUARD_BLOCK`, `LOGIN_GUARD_MAX_BLOCK` | окно подсчёта; первая блокировка; потолок (каждая следующая блокировка того же IP вдвое дольше) | `15m`, `15m`, `24h` |
| `LOGIN_GUARD_TRUSTED_IPS` | IP через запятую, которые не блокируются по IP (ящик всё равно считается) | — |
### Защита от перебора паролей
Один счётчик на всё, где проверяется пароль: `AUTH` в SMTP (25/587/465), `LOGIN`/`AUTHENTICATE` в IMAP, вход в веб-почту, смена пароля.
- **По IP:** 10 неверных паролей за 15 минут → IP заблокирован на 15 минут, следующая блокировка — 30 минут, потом час… до суток. Сутки тишины — счёт сначала.
- **По ящику:** 20 неверных паролей на один ящик с разных IP (распределённый перебор) → ящик закрыт на 15 минут, **кроме** IP, с которых в него уже входили успешно (30 дней) — так перебором нельзя запереть владельца из его же почтовой программы.
- Пока блокировка — пароль **не проверяется** вовсе: SMTP `421 4.7.0` и обрыв, IMAP `* BYE` + `NO [UNAVAILABLE]`, веб `429` с `Retry-After`. Неверный пароль отвечается через секунду; после 3 неверных в одном соединении SMTP и IMAP его закрывают.
- «Нет такого ящика» и «неверный пароль» снаружи не отличить, оба считаются.
- В логе: `login guard: IP … blocked for 15 min after 10 failed logins (block #1)`.
- `X-Forwarded-For` веб-входа принимается только от прокси в частной сети (Caddy Jambotron приходит через docker/WireGuard); с публичного адреса заголовок отбрасывается — подменой IP лимит не обойти.
- Счётчики в памяти: перезапуск их сбрасывает.
## Разработка ## Разработка
@@ -80,22 +149,74 @@ cd frontend && npm install && npx ng serve # http://loca
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`. В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет. - Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
- Первый ящик — `admin@mail.test`, пароль — в логах сервиса `mail` при первом запуске. - Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли печатаются в лог сервиса `mail` при первом запуске (в Jambotron: проект → Сервисы → `mail` → Логи) и лежат в файле `/app/data/initial-passwords.txt` контейнера `mail` (том `data`, права 600): `docker exec <контейнер mail> cat /app/data/initial-passwords.txt`. Прочитать и сменить пароль в настройках.
- Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`. - Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`.
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`). - Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`). - Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
### Ловушка почты для dev и staging (75.4)
В `docker-compose.jambotron.yml` включена ловушка: `MAIL_CATCH_ALL=trap@mail.test`. Письмо на любой адрес, у которого здесь нет своего ящика, — `user@gmail.com`, `nobody@mail.test` — ложится в ящик `trap@mail.test`; письма между ящиками стенда идут как обычно. Наружу не уходит ничего (`OUTBOUND_MODE=off`), порт 25 и репутация IP не нужны. Выключить — `MAIL_CATCH_ALL=` (пусто).
Чтобы staging Jambotron слал письма сюда вместо Resend, его `spring.mail` смотрит на SMTP этого сервера: хост — адрес, по которому staging видит SMTP 3025, без логина и без TLS. Как: в Jambotron, карточка «Домены» проекта → «Внутренние TCP-порты» → сервис `mail`, порт `3025` (Jambotron PR #781). Адрес `vmIp:vmPort` доступен только в сети WireGuard Jambotron, из интернета — нет.
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root. Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
### Готовый Docker-образ
Как у htop-web: Drone (`.drone.yml`) собирает `backend/Dockerfile`. На PR образ только собирается, на `master` уходит в реестр Gitea:
```
gitea.jambotron.com/jambotron_gang/reactive-mail-server:latest
gitea.jambotron.com/jambotron_gang/reactive-mail-server:<первые 8 символов коммита master>
```
Реестр публичный: `docker pull` без логина.
`docker-compose.image.yml` — тот же стенд, что `docker-compose.jambotron.yml`, но сервис `mail` берётся из образа, на ВМ ничего не собирается (деплой быстрее, Node и Gradle на ВМ не нужны). В мастере деплоя Jambotron выбрать этот файл; домен — сервису `mail`, порт `8080`. По умолчанию `latest`, он скачивается заново при каждом деплое (`pull_policy: always`); закрепить сборку — переменная `MAIL_IMAGE_TAG=<8 символов коммита>`.
```bash
docker compose -f docker-compose.image.yml up -d
```
### Настоящая почта в интернете (75.6)
`docker-compose.public.yml` — приём писем для `MAIL_DOMAINS` и отправка наружу прямо на MX получателей (`OUTBOUND_MODE=mx`, порт 25), с подписью DKIM; регистрация закрыта, ловушки нет. Нужна своя ВМ с публичным адресом (Jambotron 60.8: «Публичный адрес», SMTP «да» — порт 25 по умолчанию закрыт). На этом адресе открыты:
| Порт | Что |
|---|---|
| 25 | SMTP: письма от других серверов (STARTTLS) |
| 587 | submission для почтовых программ (STARTTLS + вход) — тот же SMTP, что 25 |
| 465 | SMTPS, неявный TLS |
| 993 | IMAPS, неявный TLS |
| 80 | только Let's Encrypt (HTTP-01), сервис `acme` |
Сертификат для `MAIL_HOSTNAME` выпускает и продлевает сервис `acme` (lego, ключ EC256, продление за 30 дней, проверка дважды в сутки); хук кладёт пару в том `tls`, сервер перечитывает её сам. `mail` стартует после первого сертификата. Веб-почта (сервис `mail`, порт `8080`) — через Caddy Jambotron под **другим** именем, например `webmail.<домен>`: `MAIL_HOSTNAME` смотрит на публичный адрес ВМ, а не на Caddy.
Переменные: `MAIL_DOMAINS` (например `jambocheck.com`), `MAIL_HOSTNAME` (`mail.jambocheck.com`), `ACME_EMAIL` (по умолчанию `postmaster@<домен>`).
DNS до первого запуска:
| Запись | Значение |
|---|---|
| `A mail.<домен>` | публичный адрес ВМ |
| PTR публичного адреса | `mail.<домен>` (панель хостера, Reverse DNS) |
| `MX <домен>` | `10 mail.<домен>` |
| `TXT <домен>` | `v=spf1 ip4:<публичный адрес> -all` |
| `TXT _dmarc.<домен>` | `v=DMARC1; p=none; rua=mailto:postmaster@<домен>` |
| `TXT mail._domainkey.<домен>` | из лога `mail` при первом запуске (ключ DKIM лежит в томе `data`) |
Ящик `admin@<домен>` — пароль в логе `mail` при первом запуске и в `/app/data/initial-passwords.txt`.
## Не сделано ## Не сделано
- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети. - [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча). - [x] Защита входа от перебора: SMTP, IMAP, веб (лимит по IP и по ящику, см. «Защита от перебора паролей»).
- [ ] Защита регистрации от массового заведения ящиков (лимит, капча) — пока регистрация на публичной почте закрыта.
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя). - [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6). - [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).
## Лицензия ## Лицензия
+2 -2
View File
@@ -24,11 +24,11 @@ RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
# Ubuntu image, curl included; own user: "mail" already exists (uid 8) # Ubuntu image, curl included; own user: "mail" already exists (uid 8)
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \ RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \
&& mkdir -p /app/data/attachments /app/data/dkim-keys && chown -R app:app /app/data && mkdir -p /app/data/attachments /app/data/dkim-keys /app/data/tls && chown -R app:app /app/data
WORKDIR /app WORKDIR /app
COPY --from=build /app/backend/build/libs/*.jar app.jar COPY --from=build /app/backend/build/libs/*.jar app.jar
USER app USER app
ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt MAIL_TLS_DIR=/app/data/tls
EXPOSE 8080 3025 EXPOSE 8080 3025
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1 CMD curl -fsS http://localhost:8080/actuator/health || exit 1
@@ -9,12 +9,27 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Locale;
/** /**
* First mailbox on an empty database: {@code mailserver.admin.email} (default admin@&lt;first domain&gt;) with * Mailboxes the server needs on start:
* {@code mailserver.admin.password}; no password given — a random one, printed to the log once. * <ul>
* <li>the first mailbox on an empty database — {@code mailserver.admin.email} (default admin@&lt;first domain&gt;)
* with {@code mailserver.admin.password};</li>
* <li>the catch-all mailbox ({@code mailserver.catch-all}) if it does not exist yet — mail to unknown addresses is
* stored there, so it must exist.</li>
* </ul>
* A generated password is printed to the log once (visible in Jambotron: project → Services → mail → Logs) and appended
* to {@code mailserver.passwords-file} (mode 600, in the data volume) — change it after the first login.
*/ */
@Configuration @Configuration
public class DataInitializer { public class DataInitializer {
@@ -24,23 +39,53 @@ public class DataInitializer {
@Bean @Bean
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains, public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
@Value("${mailserver.admin.email:}") String email, @Value("${mailserver.admin.email:}") String email,
@Value("${mailserver.admin.password:}") String password) { @Value("${mailserver.admin.password:}") String password,
@Value("${mailserver.passwords-file:}") String passwordsFile) {
return args -> { return args -> {
if (userRepository.count().block() > 0) { Path file = passwordsFile.isBlank() ? null : Path.of(passwordsFile);
return; if (userRepository.count().block() == 0) {
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(Locale.ROOT);
create(userService, address, password, "Administrator", file);
} }
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(); String catchAll = domains.catchAll();
boolean generated = password.isBlank(); if (catchAll != null && !Boolean.TRUE.equals(userRepository.existsByEmail(catchAll).block())) {
String secret = generated ? randomPassword() : password; create(userService, catchAll, "", "Mail trap", file);
userService.register(address, secret, "Administrator").block();
if (generated) {
log.warn("First mailbox created: {} — password: {} (shown once, change it)", address, secret);
} else {
log.info("First mailbox created: {}", address);
} }
}; };
} }
private static void create(UserService userService, String address, String password, String name, Path file)
throws IOException {
boolean generated = password.isBlank();
String secret = generated ? randomPassword() : password;
userService.register(address, secret, name).block();
if (!generated) {
log.info("Mailbox created: {}", address);
} else if (file != null) {
appendPassword(file, address, secret);
// also in the log: the owner sees it in Jambotron (project → Services → mail → Logs) without a shell
log.warn("Mailbox created: {} — password: {} (also in {}; change it)", address, secret, file);
} else {
log.warn("Mailbox created: {} — password: {} (shown once, change it)", address, secret);
}
}
static void appendPassword(Path file, String address, String password) throws IOException {
if (file.getParent() != null) {
Files.createDirectories(file.getParent());
}
if (!Files.exists(file)) {
Files.createFile(file);
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// not a POSIX file system (Windows) — nothing to restrict
}
}
Files.writeString(file, Instant.now() + " " + address + " " + password + System.lineSeparator(),
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
}
private static String randomPassword() { private static String randomPassword() {
byte[] bytes = new byte[18]; byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes); new SecureRandom().nextBytes(bytes);
@@ -0,0 +1,81 @@
package com.mailserver.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import java.time.Duration;
/**
* The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 →
* published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy.
* Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port
* (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron).
*/
@Configuration
public class HttpsConfig {
@Bean
@ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}")
public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.https.port}") int port) {
return new HttpsServer(handler, tls, host, port);
}
public static class HttpsServer implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(HttpsServer.class);
private final HttpHandler handler;
private final MailServerConfig.SmtpTlsContext tls;
private final String host;
private final int port;
private volatile DisposableServer server;
HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) {
this.handler = handler;
this.tls = tls;
this.host = host;
this.port = port;
}
@Override
public void start() {
server = HttpServer.create()
.host(host)
.port(port)
.secure(spec -> spec.sslContext(tls.context()))
// HSTS: Spring Security adds it for HTTPS requests by itself
.handle(new ReactorHttpHandlerAdapter(handler))
.bindNow(Duration.ofSeconds(30));
log.info("HTTPS (web client + API) listening on {}:{}", host, server.port());
}
@Override
public void stop() {
DisposableServer current = server;
if (current != null) {
current.disposeNow(Duration.ofSeconds(10));
server = null;
}
}
@Override
public boolean isRunning() {
return server != null;
}
public int port() {
return server == null ? -1 : server.port();
}
}
}
@@ -0,0 +1,61 @@
package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import com.mailserver.imap.DbImapStore;
import com.mailserver.imap.ImapServer;
import com.mailserver.imap.ImapStore;
import com.mailserver.repository.AttachmentRepository;
import com.mailserver.repository.MessageRepository;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.MailService;
import com.mailserver.service.MessageSources;
import com.mailserver.service.UserService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
/** IMAP (75.2): STARTTLS on mailserver.imap.port, implicit TLS on mailserver.imaps.port when set. */
@Configuration
public class ImapConfig {
@Bean
public ImapStore imapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
com.mailserver.service.MailboxAccess access) {
return new DbImapStore(db, users, userService, mailService, messages, attachments, sources, hostname, access);
}
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.imap.enabled", havingValue = "true", matchIfMissing = true)
public ImapServer imapServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imap.port:3143}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.imap.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral,
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, authWithoutTls, tls.context(), false,
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
}
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("'${mailserver.imap.enabled:true}' == 'true' and ${mailserver.imaps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public ImapServer imapsServer(ImapStore store, MailServerConfig.SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.imap.host:0.0.0.0}") String host,
@Value("${mailserver.imaps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.imap.max-literal:50MB}") DataSize maxLiteral,
@Value("${mailserver.imap.idle-timeout:30m}") Duration idleTimeout,
@Value("${mailserver.imap.idle-poll:5s}") Duration idlePoll) {
return new ImapServer(new ImapServer.Settings(host, port, hostname, false, tls.context(), true,
(int) maxLiteral.toBytes(), idleTimeout, idlePoll), store, guard);
}
}
@@ -1,580 +0,0 @@
package com.mailserver.config;
import com.mailserver.repository.FolderRepository;
import com.mailserver.repository.MessageRepository;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* Встроенный IMAP-сервер для почтового сервера.
* Поддерживает основные команды IMAP4rev1: LOGIN, SELECT, FETCH, SEARCH, STORE, EXPUNGE, LOGOUT.
* Запускается при старте приложения и останавливается при завершении.
*/
@Configuration
public class ImapServerConfig {
private static final Logger log = LoggerFactory.getLogger(ImapServerConfig.class);
@Value("${mailserver.imap.enabled:true}")
private boolean enabled;
@Value("${mailserver.imap.port:143}")
private int port;
@Value("${mailserver.imap.hostname:localhost}")
private String hostname;
private final UserRepository userRepository;
private final FolderRepository folderRepository;
private final MessageRepository messageRepository;
private final UserService userService;
private ServerSocket serverSocket;
private ExecutorService executorService;
public ImapServerConfig(UserRepository userRepository, FolderRepository folderRepository,
MessageRepository messageRepository, UserService userService) {
this.userRepository = userRepository;
this.folderRepository = folderRepository;
this.messageRepository = messageRepository;
this.userService = userService;
}
@PostConstruct
public void start() {
if (!enabled) {
log.info("IMAP-сервер отключён (mailserver.imap.enabled=false)");
return;
}
executorService = Executors.newCachedThreadPool();
try {
serverSocket = new ServerSocket(port);
log.info("IMAP-сервер запущен на порту {} (хост: {})", port, hostname);
// Принимаем подключения в цикле
while (!serverSocket.isClosed()) {
try {
Socket clientSocket = serverSocket.accept();
executorService.submit(() -> handleClient(clientSocket));
} catch (IOException e) {
if (!serverSocket.isClosed()) {
log.error("Ошибка при принятии подключения", e);
}
}
}
} catch (IOException e) {
if (serverSocket == null || !serverSocket.isClosed()) {
log.error("Не удалось запустить IMAP-сервер на порту {}", port, e);
}
}
}
@PreDestroy
public void stop() {
log.info("Остановка IMAP-сервера...");
try {
if (serverSocket != null && !serverSocket.isClosed()) {
serverSocket.close();
}
} catch (IOException e) {
log.error("Ошибка при остановке IMAP-сервера", e);
}
if (executorService != null) {
executorService.shutdown();
try {
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
}
log.info("IMAP-сервер остановлен");
}
private void handleClient(Socket socket) {
String clientAddress = socket.getInetAddress().getHostAddress();
log.info("Новое IMAP-подключение от {}", clientAddress);
try (socket;
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) {
// Отправляем приветствие
out.println("* OK IMAP4rev1 Server Ready");
String username = null;
String line;
while (username == null && (line = in.readLine()) != null) {
String[] parts = parseCommand(line);
if (parts == null) continue;
String tag = parts[0];
String command = parts[1].toUpperCase();
switch (command) {
case "LOGIN" -> {
if (parts.length >= 4) {
String email = parts[2].toLowerCase();
String password = parts[3];
// Проверяем пароль через BCrypt
com.mailserver.domain.User user = userRepository.findByEmail(email).block();
if (user != null && userService.checkPassword(user, password)) {
username = email;
out.println(tag + " OK LOGIN completed");
log.info("Пользователь {} успешно авторизован через IMAP", email);
} else {
out.println(tag + " NO LOGIN failed: invalid credentials");
log.warn("Неудачная попытка IMAP-авторизации для пользователя {}", email);
}
} else {
out.println(tag + " BAD Invalid LOGIN command");
}
}
case "CAPABILITY" -> {
out.println("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE LITERAL+");
out.println(tag + " OK Capability completed");
}
case "LOGOUT" -> {
out.println("* BYE IMAP4rev1 server terminating");
out.println(tag + " OK LOGOUT completed");
return;
}
default -> {
if (username == null) {
out.println(tag + " BAD Authentication required");
} else {
handleAuthenticatedCommand(tag, command, parts, username, out, in);
}
}
}
}
} catch (IOException e) {
log.error("Ошибка при обработке IMAP-подключения от {}", clientAddress, e);
} finally {
log.info("IMAP-подключение от {} закрыто", clientAddress);
}
}
private void handleAuthenticatedCommand(String tag, String command, String[] parts,
String username, PrintWriter out, BufferedReader in) {
switch (command) {
case "SELECT" -> handleSelect(tag, parts, username, out);
case "EXAMINE" -> handleSelect(tag, parts, username, out); // EXAMINE = SELECT в read-only режиме
case "FETCH" -> handleFetch(tag, parts, username, out);
case "SEARCH" -> handleSearch(tag, parts, username, out);
case "STORE" -> handleStore(tag, parts, username, out);
case "EXPUNGE" -> handleExpunge(tag, username, out);
case "LIST" -> handleList(tag, username, out);
case "LSUB" -> handleList(tag, username, out); // LSUB = LIST для подписок
case "STATUS" -> handleStatus(tag, parts, username, out);
case "ID" -> {
out.println("* ID (\"name\" \"MailServer IMAP\" \"version\" \"1.0\")");
out.println(tag + " OK ID completed");
}
default -> {
out.println(tag + " BAD Unknown command: " + command);
}
}
}
private void handleSelect(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 3) {
out.println(tag + " BAD Invalid SELECT command");
return;
}
String folderName = parts[2].replace("\"", "");
com.mailserver.domain.User userTemp = userRepository.findByEmail(username).block();
if (userTemp == null) {
out.println(tag + " NO User not found");
return;
}
com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(
userTemp.getId(), folderName).block();
if (folder == null) {
out.println(tag + " NO [NONEXISTENT] Mailbox does not exist");
return;
}
long messageCount = messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1)).count().block();
out.println("* " + messageCount + " EXISTS");
out.println("* 0 RECENT");
out.println("* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)");
out.println(tag + " [OK] [UNSEEN 1] " + (messageCount > 0 ? "There are unseen messages in the mailbox." : "The mailbox has no unseen messages."));
out.println(tag + " OK [READ-WRITE] SELECT completed");
}
private void handleFetch(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 4) {
out.println(tag + " BAD Invalid FETCH command");
return;
}
String messageId = parts[2];
String item = parts[3];
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
// Получаем все сообщения пользователя
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
java.util.List<com.mailserver.domain.Message> allMessages = new java.util.ArrayList<>();
for (com.mailserver.domain.Folder folder : folders) {
allMessages.addAll(messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block());
}
// Находим сообщение по UID или индексу
com.mailserver.domain.Message message = null;
if (item.contains("UID")) {
// UID FETCH
try {
long uid = Long.parseLong(messageId);
for (com.mailserver.domain.Message msg : allMessages) {
if (msg.getId().equals(uid)) {
message = msg;
break;
}
}
} catch (NumberFormatException e) {
out.println(tag + " BAD Invalid UID");
return;
}
} else {
// FETCH по индексу (1-based)
try {
int index = Integer.parseInt(messageId) - 1;
if (index >= 0 && index < allMessages.size()) {
message = allMessages.get(index);
}
} catch (NumberFormatException e) {
out.println(tag + " BAD Invalid message set");
return;
}
}
if (message == null) {
out.println(tag + " NO Message not found");
return;
}
// Формируем ответ FETCH
StringBuilder flags = new StringBuilder();
if (message.isRead()) flags.append("\\Seen ");
if (message.isFlagged()) flags.append("\\Flagged ");
if (message.isSpam()) flags.append("\\Deleted ");
if (message.getBody() != null && !message.getBody().isEmpty()) flags.append("\\Answered ");
out.println("* " + messageId + " FETCH (");
out.println(" UID " + message.getId());
out.println(" FLAGS (" + flags.toString().trim() + ")");
out.println(" INTERNALDATE \"" + formatImapDate(message.getReceivedAt()) + "\"");
out.println(" RFC822.SIZE " + (message.getMessageSize() != null ? message.getMessageSize() : 0));
out.println(" ENVELOPE (");
out.println(" \"" + escapeString(message.getReceivedAt() != null ? message.getReceivedAt().toString() : "") + "\"");
out.println(" \"" + escapeString(message.getSubject() != null ? message.getSubject() : "") + "\"");
out.println(" (\"" + escapeString(message.getSenderName() != null ? message.getSenderName() : "") + "\" 0 \"\" \"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")");
out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")");
out.println(" (\"" + escapeString(message.getRecipientEmail() != null ? message.getRecipientEmail() : "") + "\")");
out.println(" (\"" + escapeString(message.getSenderEmail() != null ? message.getSenderEmail() : "") + "\")");
out.println(" NIL");
out.println(" NIL");
out.println(" NIL");
out.println(" )");
// Если запрошено тело сообщения
if (item.contains("BODY[]") || item.contains("RFC822")) {
out.println(" BODY[] {");
String body = message.getBody() != null ? message.getBody() : "";
out.println(body.length());
out.println(body);
out.println(")");
}
out.println(")");
out.println(tag + " OK FETCH completed");
}
private void handleSearch(String tag, String[] parts, String username, PrintWriter out) {
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
com.mailserver.domain.Folder inbox = folderRepository.findByUserIdAndName(user.getId(), "INBOX").block();
if (inbox == null) {
out.println(tag + " OK SEARCH completed");
return;
}
// Простой поиск по ключевому слову
String keyword = null;
for (int i = 2; i < parts.length; i++) {
if (!parts[i].equals("ALL") && !parts[i].equals("FROM") && !parts[i].equals("SUBJECT") && !parts[i].equals("BODY")) {
keyword = parts[i];
break;
}
}
java.util.List<Long> messageIds;
if (keyword != null && !keyword.isEmpty()) {
var results = messageRepository.searchInFolder(inbox.getId(), keyword,
org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block();
messageIds = results.stream()
.map(com.mailserver.domain.Message::getId)
.collect(java.util.stream.Collectors.toList());
} else {
var results = messageRepository.findByFolderId(inbox.getId(),
org.springframework.data.domain.PageRequest.of(0, 100)).collectList().block();
messageIds = results.stream()
.map(com.mailserver.domain.Message::getId)
.collect(java.util.stream.Collectors.toList());
}
StringBuilder ids = new StringBuilder();
for (Long id : messageIds) {
ids.append(id).append(" ");
}
out.println("* SEARCH " + ids.toString().trim());
out.println(tag + " OK SEARCH completed");
}
private void handleStore(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 5) {
out.println(tag + " BAD Invalid STORE command");
return;
}
String messageId = parts[2];
String item = parts[3].toUpperCase();
String value = parts[4].toUpperCase();
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
// Получаем все сообщения пользователя
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
java.util.List<com.mailserver.domain.Message> allMessages = new java.util.ArrayList<>();
for (com.mailserver.domain.Folder folder : folders) {
allMessages.addAll(messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block());
}
com.mailserver.domain.Message message = null;
try {
int index = Integer.parseInt(messageId) - 1;
if (index >= 0 && index < allMessages.size()) {
message = allMessages.get(index);
}
} catch (NumberFormatException e) {
out.println(tag + " BAD Invalid message set");
return;
}
if (message == null) {
out.println(tag + " NO Message not found");
return;
}
boolean saved = false;
if (item.contains("+FLAGS") || item.contains("FLAGS")) {
if (value.contains("\\SEEN")) {
message.setRead(true);
saved = true;
}
if (value.contains("\\FLAGGED")) {
message.setFlagged(true);
saved = true;
}
if (value.contains("\\DELETED")) {
message.setSpam(true); // Используем spam как флаг удаления для IMAP
saved = true;
}
}
if (item.contains("-FLAGS")) {
if (value.contains("\\SEEN")) {
message.setRead(false);
saved = true;
}
if (value.contains("\\FLAGGED")) {
message.setFlagged(false);
saved = true;
}
}
if (saved) {
messageRepository.save(message).block();
}
out.println("* " + messageId + " FETCH (FLAGS (" + formatFlags(message) + "))");
out.println(tag + " OK STORE completed");
}
private void handleExpunge(String tag, String username, PrintWriter out) {
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
for (com.mailserver.domain.Folder folder : folders) {
var messages = messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1000)).collectList().block();
for (com.mailserver.domain.Message message : messages) {
if (message.isSpam()) {
messageRepository.delete(message).block();
folder.setMessageCount(Math.max(0, folder.getMessageCount() - 1));
folderRepository.save(folder).block();
}
}
}
out.println("* 0 EXPUNGE");
out.println(tag + " OK EXPUNGE completed");
}
private void handleList(String tag, String username, PrintWriter out) {
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
java.util.List<com.mailserver.domain.Folder> folders = folderRepository.findByUserId(user.getId()).collectList().block();
for (com.mailserver.domain.Folder folder : folders) {
out.println("* LIST (\\HasNoChildren) \".\" \"" + folder.getName() + "\"");
}
out.println(tag + " OK LIST completed");
}
private void handleStatus(String tag, String[] parts, String username, PrintWriter out) {
if (parts.length < 4) {
out.println(tag + " BAD Invalid STATUS command");
return;
}
String folderName = parts[2].replace("\"", "");
com.mailserver.domain.User user = userRepository.findByEmail(username).block();
if (user == null) {
out.println(tag + " NO User not found");
return;
}
com.mailserver.domain.Folder folder = folderRepository.findByUserIdAndName(user.getId(), folderName).block();
if (folder == null) {
out.println(tag + " NO [NONEXISTENT] Mailbox does not exist");
return;
}
long messageCount = messageRepository.findByFolderId(folder.getId(),
org.springframework.data.domain.PageRequest.of(0, 1)).count().block();
long unreadCount = messageRepository.countUnreadByFolderId(folder.getId()).block();
out.println("* STATUS " + folderName + " (MESSAGES " + messageCount + " UNSEEN " + unreadCount + " UIDNEXT " + (messageCount + 1) + " UIDVALIDITY 1)");
out.println(tag + " OK STATUS completed");
}
private String[] parseCommand(String line) {
if (line == null || line.trim().isEmpty()) return null;
line = line.trim();
int spaceIndex = line.indexOf(' ');
if (spaceIndex == -1) return new String[]{line, ""};
String tag = line.substring(0, spaceIndex);
String rest = line.substring(spaceIndex + 1).trim();
// Разбираем команду и аргументы
String[] parts = new String[20]; // Максимум 20 аргументов
parts[0] = tag;
int index = 1;
int i = 0;
while (i < rest.length() && index < parts.length) {
if (rest.charAt(i) == '"') {
// Строка в кавычках
int end = rest.indexOf('"', i + 1);
if (end == -1) break;
parts[index++] = rest.substring(i + 1, end);
i = end + 1;
} else if (Character.isWhitespace(rest.charAt(i))) {
i++;
} else {
int end = rest.indexOf(' ', i);
if (end == -1) end = rest.length();
parts[index++] = rest.substring(i, end);
i = end;
}
}
// Обрезаем массив до нужного размера
String[] result = new String[index];
System.arraycopy(parts, 0, result, 0, index);
return result;
}
private String formatImapDate(Instant instant) {
if (instant == null) return "";
// Формат IMAP: "dd-MMM-yyyy hh:mm:ss +0000"
String[] months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
java.time.LocalDate date = instant.atZone(java.time.ZoneOffset.UTC).toLocalDate();
java.time.LocalTime time = instant.atZone(java.time.ZoneOffset.UTC).toLocalTime();
return String.format("%02d-%s-%04d %02d:%02d:%02d +0000",
date.getDayOfMonth(), months[date.getMonthValue() - 1], date.getYear(),
time.getHour(), time.getMinute(), time.getSecond());
}
private String escapeString(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\").replace("\"", "\\\"");
}
private String formatFlags(com.mailserver.domain.Message message) {
StringBuilder flags = new StringBuilder();
if (message.isRead()) flags.append("\\Seen ");
if (message.isFlagged()) flags.append("\\Flagged ");
if (message.isSpam()) flags.append("\\Deleted ");
return flags.toString().trim();
}
}
@@ -0,0 +1,74 @@
package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.server.adapter.ForwardedHeaderTransformer;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/** Brute-force protection (one guard for SMTP, IMAP and the web) and whose X-Forwarded-* headers to believe. */
@Configuration
public class LoginGuardConfig {
@Bean
public LoginGuard loginGuard(@Value("${mailserver.login-guard.enabled:true}") boolean enabled,
@Value("${mailserver.login-guard.max-per-ip:10}") int maxPerIp,
@Value("${mailserver.login-guard.max-per-account:20}") int maxPerAccount,
@Value("${mailserver.login-guard.window:15m}") Duration window,
@Value("${mailserver.login-guard.block:15m}") Duration block,
@Value("${mailserver.login-guard.max-block:24h}") Duration maxBlock,
@Value("${mailserver.login-guard.known-ip-ttl:30d}") Duration knownIpTtl,
@Value("${mailserver.login-guard.trusted-ips:}") String trustedIps,
@Value("${mailserver.login-guard.failure-delay:1s}") Duration failureDelay) {
Set<String> trusted = Arrays.stream(trustedIps.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toUnmodifiableSet());
return new LoginGuard(new LoginGuard.Settings(enabled, maxPerIp, maxPerAccount, window, block, maxBlock,
knownIpTtl, trusted, failureDelay));
}
/**
* Replaces Boot's transformer (server.forward-headers-strategy=framework): X-Forwarded-* / Forwarded are believed
* only from a proxy in a private network (Jambotron's Caddy comes over the docker network / WireGuard). A request
* straight from the internet (the VM's public address) has them removed — otherwise anyone could put a new
* X-Forwarded-For into every login attempt and never hit the per-IP limit.
*/
@Bean(name = "forwardedHeaderTransformer")
public ForwardedHeaderTransformer forwardedHeaderTransformer() {
return new TrustedProxyHeaderTransformer();
}
static final class TrustedProxyHeaderTransformer extends ForwardedHeaderTransformer {
private final ForwardedHeaderTransformer stripOnly = new ForwardedHeaderTransformer();
TrustedProxyHeaderTransformer() {
stripOnly.setRemoveOnly(true);
}
@Override
public ServerHttpRequest apply(ServerHttpRequest request) {
return privatePeer(request.getRemoteAddress()) ? super.apply(request) : stripOnly.apply(request);
}
}
/** Loopback, 10/8, 172.16/12, 192.168/16, link-local, IPv6 fc00::/7. */
static boolean privatePeer(InetSocketAddress peer) {
if (peer == null || peer.getAddress() == null) {
return false;
}
InetAddress a = peer.getAddress();
if (a.isLoopbackAddress() || a.isSiteLocalAddress() || a.isLinkLocalAddress()) {
return true;
}
byte[] b = a.getAddress();
return b.length == 16 && (b[0] & 0xfe) == 0xfc;
}
}
@@ -0,0 +1,52 @@
package com.mailserver.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 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;; plus mailboxes Jambotron marks as admin
* 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
public class MailAdmins {
private final Set<String> admins;
private final Set<String> granted = java.util.concurrent.ConcurrentHashMap.newKeySet();
public MailAdmins(@Value("${mailserver.admins:}") String admins,
@Value("${mailserver.admin.email:}") String firstMailbox,
MailDomains domains) {
String list = !admins.isBlank() ? admins
: !firstMailbox.isBlank() ? firstMailbox
: "admin@" + domains.domains().iterator().next();
this.admins = Arrays.stream(list.split(","))
.map(a -> a.trim().toLowerCase(Locale.ROOT))
.filter(a -> !a.isEmpty())
.collect(Collectors.toUnmodifiableSet());
}
public boolean isAdmin(String email) {
if (email == null) {
return false;
}
String key = email.toLowerCase(Locale.ROOT);
return admins.contains(key) || granted.contains(key);
}
/** Given / taken by Jambotron (users.is_admin) — loaded at start and 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,32 @@
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;
/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */
@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().filter(user -> user.isAdmin())
.doOnNext(user -> admins.grant(user.getEmail(), true))
.count()
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n),
e -> log.error("Mail administrators from Jambotron 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);
@@ -1,14 +1,19 @@
package com.mailserver.config; package com.mailserver.config;
import com.mailserver.security.LoginGuard;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer; import com.mailserver.smtp.SmtpServer;
import com.mailserver.smtp.SmtpTls;
import io.netty.handler.ssl.SslContext;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.Locale; import java.util.Locale;
@@ -17,16 +22,46 @@ import java.util.Locale;
@EnableScheduling @EnableScheduling
public class MailServerConfig { public class MailServerConfig {
/**
* Certificate for SMTP TLS: mailserver.tls.cert-file + key-file (PEM), or a self-signed one in mailserver.tls.dir.
* mailserver.tls.enabled=false — no TLS at all.
*/
@Bean
public SmtpTlsContext smtpTlsContext(@Value("${mailserver.tls.enabled:true}") boolean enabled,
@Value("${mailserver.tls.cert-file:}") String certFile,
@Value("${mailserver.tls.key-file:}") String keyFile,
@Value("${mailserver.tls.dir:./tls}") String dir,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) throws Exception {
return new SmtpTlsContext(enabled ? SmtpTls.serverContext(certFile, keyFile, Path.of(dir), hostname) : null);
}
public record SmtpTlsContext(SslContext context) {
}
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
public SmtpServer smtpServer(MailRouter router, public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls, @Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router); return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
tls.context(), false), router, guard);
}
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls, LoginGuard guard,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
tls.context(), true), router, guard);
} }
/** /**
@@ -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/**").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()
@@ -1,5 +1,6 @@
package com.mailserver.controller; package com.mailserver.controller;
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.LoginRequest; import com.mailserver.dto.LoginRequest;
@@ -10,11 +11,16 @@ import com.mailserver.dto.ProfileUpdateRequest;
import com.mailserver.dto.RegisterRequest; import com.mailserver.dto.RegisterRequest;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.security.LoginGuard;
import com.mailserver.util.JwtUtils; import com.mailserver.util.JwtUtils;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -25,6 +31,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -34,6 +42,10 @@ import java.util.Set;
@RequestMapping("/api") @RequestMapping("/api")
public class AuthenticationController { public class AuthenticationController {
private static final Logger log = LoggerFactory.getLogger(AuthenticationController.class);
/** Marker for "no such mailbox or wrong password": both look the same from outside. */
private static final User NO_USER = new User();
/** Service addresses nobody can register (RFC 2142 and ours). */ /** Service addresses nobody can register (RFC 2142 and ours). */
static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster", static final Set<String> RESERVED = Set.of("postmaster", "abuse", "mailer-daemon", "hostmaster", "webmaster",
"root", "admin", "noreply", "no-reply", "security", "support"); "root", "admin", "noreply", "no-reply", "security", "support");
@@ -43,9 +55,14 @@ public class AuthenticationController {
private final JwtUtils jwtUtils; private final JwtUtils jwtUtils;
private final MailDomains domains; private final MailDomains domains;
private final boolean registrationEnabled; private final boolean registrationEnabled;
private final LoginGuard guard;
private final MailAdmins admins;
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) {
this.guard = guard;
this.admins = admins;
this.userService = userService; this.userService = userService;
this.mailService = mailService; this.mailService = mailService;
this.jwtUtils = jwtUtils; this.jwtUtils = jwtUtils;
@@ -60,23 +77,38 @@ public class AuthenticationController {
} }
@PostMapping("/login") @PostMapping("/login")
public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono) { public Mono<ResponseEntity<?>> login(@Valid @RequestBody Mono<LoginRequest> requestMono, ServerHttpRequest http) {
return requestMono.flatMap(request -> String ip = clientIp(http);
userService.findByEmail(request.getEmail().trim().toLowerCase(Locale.ROOT)) return requestMono.flatMap(request -> {
.switchIfEmpty(Mono.error(new IllegalArgumentException("Invalid credentials"))) String email = request.getEmail().trim().toLowerCase(Locale.ROOT);
Duration wait = guard.retryAfter(ip, email);
if (!wait.isZero()) {
log.warn("WEB {}: login as {} refused, blocked for {}", ip, email, LoginGuard.human(wait));
return Mono.just(tooMany(wait));
}
return userService.findByEmail(email)
.map(user -> userService.checkPassword(user, request.getPassword()) ? user : NO_USER)
.onErrorResume(IllegalArgumentException.class, e -> Mono.just(NO_USER))
.defaultIfEmpty(NO_USER)
.flatMap(user -> { .flatMap(user -> {
if (user == NO_USER) {
guard.failure(ip, email);
log.warn("WEB {}: authentication failed for {}", ip, email);
Mono<ResponseEntity<?>> answer = Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
Duration delay = guard.failureDelay();
return delay.isZero() ? answer : answer.delayElement(delay);
}
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")));
} }
if (!userService.checkPassword(user, request.getPassword())) {
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")));
}
return Mono.just(ResponseEntity.ok(token(user))); return Mono.just(ResponseEntity.ok(token(user)));
}) });
.onErrorResume(IllegalArgumentException.class, e -> });
Mono.just(ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials")))
)
);
} }
/** New mailbox in one of our domains; logs in right away (the answer carries the token). */ /** New mailbox in one of our domains; logs in right away (the answer carries the token). */
@@ -110,22 +142,61 @@ 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);
} }
@PutMapping("/me/password") @PutMapping("/me/password")
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,
return user(auth).zipWith(body) ServerHttpRequest http) {
.flatMap(pair -> userService.changePassword(pair.getT1(), pair.getT2().currentPassword(), pair.getT2().newPassword())) String ip = clientIp(http);
return own(auth).zipWith(body)
.flatMap(pair -> {
User user = pair.getT1();
// Someone with a stolen session must not guess the current password either
Duration wait = guard.retryAfter(ip, user.getEmail());
if (!wait.isZero()) {
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS,
"Too many failed attempts, try again in " + LoginGuard.human(wait)));
}
if (!userService.checkPassword(user, pair.getT2().currentPassword())) {
guard.failure(ip, user.getEmail());
}
return userService.changePassword(user, pair.getT2().currentPassword(), pair.getT2().newPassword());
})
.thenReturn(Map.of("message", "Password changed")); .thenReturn(Map.of("message", "Password changed"));
} }
private static ResponseEntity<?> tooMany(Duration wait) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(Math.max(1, (wait.toMillis() + 999) / 1000)))
.body(Map.of("error", "Too many failed logins, try again in " + LoginGuard.human(wait)));
}
/** Client address; behind Caddy it comes from X-Forwarded-For (server.forward-headers-strategy=framework). */
static String clientIp(ServerHttpRequest http) {
InetSocketAddress address = http.getRemoteAddress();
if (address == null) {
return "unknown";
}
return address.getAddress() != null ? address.getAddress().getHostAddress() : address.getHostString();
}
private Mono<ProfileResponse> profile(User user) { private Mono<ProfileResponse> profile(User user) {
return mailService.usedBytes(user.getId()) return mailService.usedBytes(user.getId())
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, .map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt())); user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(),
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) {
@@ -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 ---
@@ -0,0 +1,250 @@
package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains;
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.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.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.PutMapping;
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.Instant;
import java.util.Comparator;
import java.util.List;
/**
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
* support@ — registration stays closed), new password, switch off. A generated password is returned once, in the
* answer — it is not logged.
*/
@RestController
@RequestMapping("/api/admin/mailboxes")
public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
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) {
}
/** @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,
@Size(max = 200) String password, Boolean shared) {
}
public record PasswordRequest(@Size(max = 200) String password) {
}
public record ActiveRequest(boolean active) {
}
public record TrapRequest(boolean trap) {
}
/** @param password the generated password, or null when the administrator chose it */
public record Created(MailboxView mailbox, String password) {
}
private final UserService users;
private final MailService mail;
private final MailDomains domains;
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, JwtUtils jwt,
MailDirectory directory, MailLists lists, MailboxAccess access) {
this.access = access;
this.jwt = jwt;
this.directory = directory;
this.lists = lists;
this.users = users;
this.mail = mail;
this.domains = domains;
this.admins = admins;
}
@GetMapping
public Flux<MailboxView> list(Mono<Authentication> auth) {
return admin(auth).thenMany(users.all())
.concatMap(this::view)
.sort(Comparator.comparing(MailboxView::email));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return admin(auth).then(body).flatMap(request -> {
String chosen = request.password() == null ? "" : request.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"));
}
boolean shared = Boolean.TRUE.equals(request.shared());
String password = chosen.isEmpty() || shared ? randomPassword() : chosen;
return directory.createMailbox(request.email(), request.displayName(), password)
.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)
.map(view -> new Created(view, chosen.isEmpty() && !shared ? password : null));
});
}
/** New password: the given one, or a generated one returned once. */
@PostMapping("/{id}/password")
public Mono<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> body) {
return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> {
String chosen = request.password() == null ? "" : request.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"));
}
String password = chosen.isEmpty() ? randomPassword() : chosen;
return mailbox(id)
.flatMap(user -> users.setPassword(user, password))
.doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail()))
.flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
});
}
/**
* 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. */
@PutMapping("/{id}/active")
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
if (!request.active() && user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not switch off your own mailbox"));
}
log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off");
return users.setActive(user, request.active());
}))).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));
}));
}
/** 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) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName)
.filter(admins::isAdmin)
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators")));
}
private Mono<User> mailbox(Long id) {
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
}
private Mono<MailboxView> view(User user) {
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
}
private static ResponseStatusException bad(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
static String randomPassword() {
return MailDirectory.randomPassword();
}
}
@@ -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);
}
}
}
@@ -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,18 @@ 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;
/** 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 +66,14 @@ 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 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; }
@@ -2,5 +2,7 @@ package com.mailserver.dto;
import java.time.Instant; import java.time.Instant;
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) { /** @param admin may manage mailboxes ({@link com.mailserver.config.MailAdmins}) */
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt,
boolean admin) {
} }
@@ -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; }
@@ -0,0 +1,286 @@
package com.mailserver.imap;
import com.mailserver.domain.Attachment;
import com.mailserver.domain.Message;
import com.mailserver.domain.User;
import com.mailserver.repository.AttachmentRepository;
import com.mailserver.repository.MessageRepository;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailService;
import com.mailserver.service.MailboxAccess;
import com.mailserver.service.MessageSources;
import com.mailserver.service.UserService;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.web.server.ResponseStatusException;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.UUID;
/** {@link ImapStore} over the mail server's PostgreSQL tables (the same data the web client shows). */
public class DbImapStore implements ImapStore {
private static final Logger log = LoggerFactory.getLogger(DbImapStore.class);
private final DatabaseClient db;
private final UserRepository users;
private final UserService userService;
private final MailService mailService;
private final MessageRepository messages;
private final AttachmentRepository attachments;
private final MessageSources sources;
private final String hostname;
private final MailboxAccess access;
public DbImapStore(DatabaseClient db, UserRepository users, UserService userService, MailService mailService,
MessageRepository messages, AttachmentRepository attachments, MessageSources sources,
String hostname, MailboxAccess access) {
this.access = access;
this.db = db;
this.users = users;
this.userService = userService;
this.mailService = mailService;
this.messages = messages;
this.attachments = attachments;
this.sources = sources;
this.hostname = hostname;
}
@Override
public Account authenticate(String username, String password) {
// login or mailbox*login (shared / attached mailbox in a mail program)
User user = access.login(username, password);
return user == null ? null : new Account(user.getId(), user.getEmail());
}
@Override
public List<Mailbox> mailboxes(long userId) {
return db.sql("SELECT id, name, is_system, uid_validity, uid_next FROM folders WHERE user_id = :u ORDER BY id")
.bind("u", userId)
.map(row -> new Mailbox(row.get("id", Long.class), row.get("name", String.class),
Boolean.TRUE.equals(row.get("is_system", Boolean.class)),
row.get("uid_validity", Long.class), row.get("uid_next", Long.class)))
.all().collectList().block();
}
@Override
public Mailbox create(long userId, String name) {
try {
var created = mailService.createFolder(userId, name).block();
return mailboxes(userId).stream().filter(m -> m.id() == created.id()).findFirst().orElseThrow();
} catch (ResponseStatusException e) {
throw new Refused(e.getReason());
}
}
@Override
public void rename(long userId, Mailbox mailbox, String newName) {
try {
mailService.renameFolder(mailbox.id(), userId, newName).block();
} catch (ResponseStatusException e) {
throw new Refused(e.getReason());
}
}
@Override
public void delete(long userId, Mailbox mailbox) {
try {
mailService.deleteFolder(mailbox.id(), userId).block();
} catch (ResponseStatusException e) {
throw new Refused(e.getReason());
}
}
@Override
public List<Entry> entries(long folderId) {
return db.sql("SELECT id, uid, is_read, is_flagged, is_answered, is_deleted, received_at FROM messages "
+ "WHERE folder_id = :f ORDER BY uid")
.bind("f", folderId)
.map(row -> new Entry(row.get("id", Long.class), row.get("uid", Long.class),
Boolean.TRUE.equals(row.get("is_read", Boolean.class)),
Boolean.TRUE.equals(row.get("is_flagged", Boolean.class)),
Boolean.TRUE.equals(row.get("is_answered", Boolean.class)),
Boolean.TRUE.equals(row.get("is_deleted", Boolean.class)),
instant(row.get("received_at"))))
.all().collectList().block();
}
@Override
public byte[] source(long messageId) {
byte[] raw = sources.get(messageId).block();
if (raw != null) {
return raw;
}
Message message = messages.findById(messageId).block();
if (message == null) {
return new byte[0];
}
try {
List<Attachment> files = attachments.findByMessageId(messageId).collectList().block();
raw = MimeSource.build(message, files, hostname);
} catch (Exception e) {
log.warn("IMAP: cannot build message {}: {}", messageId, e.toString());
return ("Subject: " + message.getSubject() + "\r\n\r\n" + message.getBody()).getBytes();
}
sources.put(messageId, raw).block();
return raw;
}
@Override
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
db.sql("UPDATE messages SET is_read = :s, is_flagged = :f, is_answered = :a, is_deleted = :d WHERE id = :id")
.bind("s", seen).bind("f", flagged).bind("a", answered).bind("d", deleted).bind("id", messageId)
.then().block();
}
@Override
public long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered,
boolean deleted, Instant internalDate) {
try {
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
Message message = new Message();
message.setFolderId(target.id());
String senderEmail = "unknown@" + hostname;
String senderName = null;
if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) {
senderEmail = from.getAddress();
senderName = from.getPersonal();
}
message.setSenderEmail(senderEmail);
message.setSenderName(senderName);
String to = LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.TO);
message.setRecipientEmail(to == null ? "" : to);
message.setCcEmail(LocalDelivery.addresses(mime, jakarta.mail.Message.RecipientType.CC));
message.setSubject(mime.getSubject() == null ? "" : mime.getSubject());
LocalDelivery.Body body = LocalDelivery.body(mime);
message.setBody(body.text());
message.setHtml(body.html());
message.setRead(seen);
message.setFlagged(flagged);
message.setSpam(target.system() && "Spam".equals(target.name()));
message.setReceivedAt(internalDate == null ? Instant.now() : internalDate);
String messageId = mime.getMessageID();
message.setMessageId(messageId == null ? UUID.randomUUID().toString() : messageId.replaceAll("^<|>$", ""));
message.setMessageSize((long) raw.length);
Message stored = messages.save(message).block();
for (LocalDelivery.FilePart file : LocalDelivery.attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
}
sources.put(stored.getId(), raw).block();
setFlags(stored.getId(), seen, flagged, answered, deleted);
return uid(stored.getId());
} catch (Refused e) {
throw e;
} catch (Exception e) {
log.warn("IMAP APPEND to {} failed: {}", target.name(), e.toString());
throw new Refused("Cannot store the message: " + e.getMessage());
}
}
@Override
public List<Long> copy(long userId, List<Entry> list, Mailbox target) {
boolean spam = target.system() && "Spam".equals(target.name());
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Long newId = db.sql("INSERT INTO messages (folder_id, sender_email, sender_name, recipient_email, cc_email, "
+ "subject, body, is_html, is_read, is_flagged, is_spam, is_answered, is_deleted, message_size, "
+ "received_at, message_id) "
+ "SELECT :t, sender_email, sender_name, recipient_email, cc_email, subject, body, is_html, "
+ "is_read, is_flagged, :spam, is_answered, is_deleted, message_size, received_at, message_id "
+ "FROM messages WHERE id = :id RETURNING id")
.bind("t", target.id()).bind("spam", spam).bind("id", entry.id())
.map(row -> row.get("id", Long.class)).one().block();
if (newId == null) {
continue;
}
for (Attachment attachment : attachments.findByMessageId(entry.id()).collectList().block()) {
copyAttachment(attachment, newId);
}
db.sql("INSERT INTO message_sources (message_id, raw) SELECT :n, raw FROM message_sources WHERE message_id = :o")
.bind("n", newId).bind("o", entry.id()).then().block();
uids.add(uid(newId));
}
return uids;
}
private void copyAttachment(Attachment attachment, long newMessageId) {
try {
Path from = Paths.get(attachment.getFilePath());
Path to = from.resolveSibling(UUID.randomUUID() + "_" + from.getFileName().toString().replaceFirst("^[0-9a-f-]{36}_", ""));
Files.copy(from, to);
Attachment copy = new Attachment();
copy.setMessageId(newMessageId);
copy.setFilename(attachment.getFilename());
copy.setContentType(attachment.getContentType());
copy.setSizeBytes(attachment.getSizeBytes());
copy.setFilePath(to.toString());
copy.setContentId(attachment.getContentId());
attachments.save(copy).block();
} catch (IOException e) {
log.warn("IMAP COPY: attachment {} not copied: {}", attachment.getFilePath(), e.toString());
}
}
@Override
public List<Long> move(long userId, List<Entry> list, Mailbox target) {
boolean spam = target.system() && "Spam".equals(target.name());
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Long uid = db.sql("UPDATE messages SET folder_id = :t, is_spam = :spam WHERE id = :id RETURNING uid")
.bind("t", target.id()).bind("spam", spam).bind("id", entry.id())
.map(row -> row.get("uid", Long.class)).one().block();
if (uid != null) {
uids.add(uid);
}
}
return uids;
}
@Override
public void expunge(List<Long> messageIds) {
for (Long id : messageIds) {
for (Attachment attachment : attachments.findByMessageId(id).collectList().block()) {
try {
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
} catch (IOException e) {
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
}
}
messages.deleteById(id).block();
}
}
private long uid(long messageId) {
return db.sql("SELECT uid FROM messages WHERE id = :id").bind("id", messageId)
.map(row -> row.get("uid", Long.class)).one().block();
}
private static Instant instant(Object value) {
if (value instanceof Instant instant) {
return instant;
}
if (value instanceof LocalDateTime local) {
return local.toInstant(ZoneOffset.UTC);
}
if (value instanceof java.time.OffsetDateTime offset) {
return offset.toInstant();
}
return Instant.EPOCH;
}
}
@@ -0,0 +1,194 @@
package com.mailserver.imap;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* Reads one IMAP command: the decoder has already collected it whole, with its literals inline
* ("{n}\r\n" + n bytes, or "{n+}\r\n" + n bytes).
*/
final class ImapReader {
static final class SyntaxError extends RuntimeException {
SyntaxError(String message) {
super(message);
}
}
private final byte[] b;
private int pos;
ImapReader(byte[] command) {
this(command, true);
}
private ImapReader(byte[] command, boolean stripLineEnd) {
int end = command.length;
if (!stripLineEnd) {
this.b = command;
return;
}
if (end > 0 && command[end - 1] == '\n') {
end--;
}
if (end > 0 && command[end - 1] == '\r') {
end--;
}
this.b = java.util.Arrays.copyOf(command, end);
}
boolean atEnd() {
return pos >= b.length;
}
int peek() {
return pos < b.length ? b[pos] & 0xff : -1;
}
void sp() {
if (peek() != ' ') {
throw new SyntaxError("Expected a space");
}
while (peek() == ' ') {
pos++;
}
}
boolean optionalSp() {
if (peek() == ' ') {
sp();
return true;
}
return false;
}
void expect(char c) {
if (peek() != c) {
throw new SyntaxError("Expected '" + c + "'");
}
pos++;
}
/** Characters up to a space, parenthesis, or the end (an atom; also tags, sequence sets, flags). */
String atom() {
int start = pos;
while (pos < b.length) {
int c = b[pos] & 0xff;
if (c == ' ' || c == '(' || c == ')' || c == '{' || c == '"' || c < 0x20 || c == 0x7f) {
break;
}
pos++;
}
if (pos == start) {
throw new SyntaxError("Expected an atom");
}
return new String(b, start, pos - start, StandardCharsets.UTF_8);
}
/** Atom, quoted string or literal. */
String astring() {
int c = peek();
if (c == '"' || c == '{') {
return new String(stringBytes(), StandardCharsets.UTF_8);
}
return atom();
}
/** Quoted string or literal, raw bytes. */
byte[] stringBytes() {
int c = peek();
if (c == '"') {
pos++;
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
while (true) {
if (pos >= b.length) {
throw new SyntaxError("Unterminated quoted string");
}
byte x = b[pos++];
if (x == '"') {
return out.toByteArray();
}
if (x == '\\' && pos < b.length) {
x = b[pos++];
}
out.write(x);
}
}
if (c == '{') {
int close = indexOf('}', pos);
if (close < 0) {
throw new SyntaxError("Bad literal");
}
String size = new String(b, pos + 1, close - pos - 1, StandardCharsets.US_ASCII);
if (size.endsWith("+")) {
size = size.substring(0, size.length() - 1);
}
int length;
try {
length = Integer.parseInt(size);
} catch (NumberFormatException e) {
throw new SyntaxError("Bad literal size");
}
pos = close + 1;
if (peek() == '\r') {
pos++;
}
expect('\n');
if (pos + length > b.length) {
throw new SyntaxError("Literal shorter than announced");
}
byte[] data = java.util.Arrays.copyOfRange(b, pos, pos + length);
pos += length;
return data;
}
throw new SyntaxError("Expected a string");
}
/** "(a b c)" → [a, b, c] (atoms or strings); an atom without parentheses → [atom]. */
List<String> list() {
List<String> items = new ArrayList<>();
if (peek() != '(') {
items.add(astring());
return items;
}
pos++;
while (peek() == ' ') {
pos++;
}
while (peek() != ')') {
if (atEnd()) {
throw new SyntaxError("Unterminated list");
}
items.add(astring());
while (peek() == ' ') {
pos++;
}
}
pos++;
return items;
}
/** Everything left, as text (used for FETCH attributes and SEARCH keys parsed by their own readers). */
String rest() {
String text = new String(b, pos, b.length - pos, StandardCharsets.UTF_8);
pos = b.length;
return text;
}
/** A reader over the rest, as is (SEARCH may hold literals). */
ImapReader restReader() {
ImapReader reader = new ImapReader(java.util.Arrays.copyOfRange(b, pos, b.length), false);
pos = b.length;
return reader;
}
private int indexOf(char c, int from) {
for (int i = from; i < b.length; i++) {
if (b[i] == c) {
return i;
}
}
return -1;
}
}
@@ -0,0 +1,273 @@
package com.mailserver.imap;
import com.mailserver.security.LoginGuard;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import reactor.core.scheduler.Schedulers;
import reactor.netty.Connection;
import reactor.netty.DisposableServer;
import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* IMAP server (RFC 3501) on Reactor Netty.
* <ul>
* <li>{@link CommandDecoder} collects whole commands with their literals ({n} — after "+ Ready", {n+} — at once);</li>
* <li>each command runs on a worker thread (database, MIME parsing), never on the event loop; responses are written
* in order, one command at a time;</li>
* <li>TLS: STARTTLS, or implicit TLS on a separate port (993); LOGIN/AUTHENTICATE only inside TLS unless
* {@code authWithoutTls}; plain text sent together with STARTTLS is never executed;</li>
* <li>IDLE: changes of the selected mailbox are pushed every {@code idlePoll} until DONE.</li>
* </ul>
*/
public class ImapServer {
private static final Logger log = LoggerFactory.getLogger(ImapServer.class);
static final int MAX_LINE = 64 * 1024;
private static final int MAX_QUEUED = 1000;
private static final Pattern LITERAL = Pattern.compile("\\{(\\d+)(\\+?)}\r?\n$");
private static final Pattern IDLE = Pattern.compile("^(\\S+) IDLE\\s*$", Pattern.CASE_INSENSITIVE);
private static final Duration IDLE_MAX = Duration.ofMinutes(29);
/**
* @param tls certificate; null — no TLS
* @param implicitTls TLS from the first byte (port 993)
* @param maxLiteral the largest literal accepted (APPEND of a big message)
* @param idlePoll how often IDLE looks for changes
*/
public record Settings(String host, int port, String hostname, boolean authWithoutTls, SslContext tls,
boolean implicitTls, int maxLiteral, Duration idleTimeout, Duration idlePoll) {
}
/** A whole command and whether it arrived over TLS. */
record Command(byte[] bytes, boolean tls) {
}
private final Settings settings;
private final ImapStore store;
private final LoginGuard guard;
private DisposableServer server;
public ImapServer(Settings settings, ImapStore store) {
this(settings, store, LoginGuard.disabled());
}
public ImapServer(Settings settings, ImapStore store, LoginGuard guard) {
this.settings = settings;
this.store = store;
this.guard = guard;
}
public void start() {
if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host())
.port(settings.port())
.doOnConnection(connection -> connection
.addHandlerFirst("imap-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("imap-commands", new CommandDecoder(settings.maxLiteral())));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp.handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"};
inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress();
}
});
ImapSession session = new ImapSession(
new ImapSession.Settings(settings.hostname(), settings.tls() != null, settings.authWithoutTls()),
store, guard, remoteIp[0], settings.implicitTls());
@SuppressWarnings("unchecked")
Sinks.Empty<Void>[] idleDone = new Sinks.Empty[1];
Flux<byte[]> responses = inbound.receiveObject().cast(Command.class)
// DONE ends IDLE while the IDLE command is still running — handled before the queue
.filter(command -> {
if (session.idling() && isDone(command.bytes())) {
if (idleDone[0] != null) {
idleDone[0].tryEmitEmpty();
}
return false;
}
return true;
})
// read commands as they come (so DONE is seen during IDLE), run them one by one
.onBackpressureBuffer(MAX_QUEUED, dropped -> log.warn("IMAP {}: too many queued commands", session.remoteIp))
.concatMap(command -> {
if (session.tls && !command.tls()) {
// plain text sent together with STARTTLS: never executed, the connection is closed
log.warn("IMAP {}: plain-text command after STARTTLS, closing", session.remoteIp);
session.closing = true;
connection[0].dispose();
return Flux.empty();
}
String text = new String(command.bytes(), StandardCharsets.UTF_8).trim();
Matcher idle = IDLE.matcher(text);
if (idle.matches()) {
idleDone[0] = Sinks.empty();
return idle(session, idle.group(1), idleDone[0]);
}
return blocking(() -> session.handle(command.bytes()));
})
.onErrorResume(e -> {
log.debug("IMAP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just(ImapSession.line("* BYE " + e.getMessage()));
})
// after the tagged line of the command that closes (LOGOUT, too many failed logins), not after
// its first untagged "* BYE" — the tagged answer must still reach the client
.takeUntil(response -> session.closing && !untagged(response));
return Flux.concat(Mono.just(session.greeting()), responses)
.concatMap(response -> response == ImapSession.STARTTLS_READY
? startTls(connection[0], session)
: send(outbound, response))
.then();
})
.bindNow(Duration.ofSeconds(10));
log.info("IMAP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private Flux<byte[]> idle(ImapSession session, String tag, Sinks.Empty<Void> done) {
Flux<byte[]> polls = Flux.interval(settings.idlePoll())
.onBackpressureDrop()
.concatMap(tick -> blocking(session::idlePoll))
.takeUntilOther(done.asMono())
.take(IDLE_MAX);
return Flux.concat(
blocking(() -> session.idleStart(tag)),
Flux.defer(() -> session.idling() ? polls : Flux.empty()),
blocking(session::idleEnd));
}
private static boolean isDone(byte[] bytes) {
return new String(bytes, StandardCharsets.US_ASCII).trim().toUpperCase(Locale.ROOT).equals("DONE");
}
private static boolean untagged(byte[] response) {
return response.length > 1 && response[0] == '*' && response[1] == ' ';
}
private static Flux<byte[]> blocking(java.util.concurrent.Callable<List<byte[]>> work) {
return Mono.fromCallable(work).subscribeOn(Schedulers.boundedElastic()).flatMapIterable(list -> list);
}
private static Mono<Void> send(NettyOutbound outbound, byte[] response) {
return outbound.send(Mono.just(Unpooled.wrappedBuffer(response))).then();
}
/** After the tagged OK of STARTTLS has left in plain text: TLS in front, a fresh decoder (no plain leftovers). */
private Mono<Void> startTls(Connection connection, ImapSession session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("imap-commands", "imap-commands", new CommandDecoder(settings.maxLiteral()));
pipeline.addFirst("imap-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (!future.isSuccess()) {
log.info("IMAP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
}
sink.success();
}));
}
public int port() {
return server.port();
}
public void stop() {
if (server != null) {
server.disposeNow(Duration.ofSeconds(5));
}
}
/**
* Collects one command: lines plus the literals they announce. A synchronizing literal {n} gets "+ Ready" first;
* {n+} (LITERAL+) is read at once. Marks whether the command was read behind TLS.
*/
static final class CommandDecoder extends ByteToMessageDecoder {
private final int maxLiteral;
private final ByteArrayOutputStream command = new ByteArrayOutputStream();
private int literalLeft;
private int lineBytes;
CommandDecoder(int maxLiteral) {
this.maxLiteral = maxLiteral;
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
while (in.isReadable()) {
if (literalLeft > 0) {
int n = Math.min(literalLeft, in.readableBytes());
byte[] chunk = new byte[n];
in.readBytes(chunk);
command.write(chunk, 0, n);
literalLeft -= n;
continue;
}
int newline = in.indexOf(in.readerIndex(), in.writerIndex(), (byte) '\n');
if (newline < 0) {
if (lineBytes + in.readableBytes() > MAX_LINE) {
throw new TooLongFrameException("Command line too long");
}
return;
}
int length = newline - in.readerIndex() + 1;
if (lineBytes + length > MAX_LINE) {
throw new TooLongFrameException("Command line too long");
}
byte[] line = new byte[length];
in.readBytes(line);
command.write(line, 0, line.length);
lineBytes += length;
Matcher literal = LITERAL.matcher(new String(line, StandardCharsets.ISO_8859_1));
if (literal.find()) {
long size = Long.parseLong(literal.group(1));
if (size > maxLiteral) {
ctx.writeAndFlush(Unpooled.copiedBuffer("* BYE Literal too large\r\n", StandardCharsets.US_ASCII));
throw new TooLongFrameException("Literal too large: " + size);
}
literalLeft = (int) size;
if (literal.group(2).isEmpty()) {
ctx.writeAndFlush(Unpooled.copiedBuffer("+ Ready for literal data\r\n", StandardCharsets.US_ASCII));
}
continue;
}
out.add(new Command(command.toByteArray(), ctx.pipeline().get(SslHandler.class) != null));
command.reset();
lineBytes = 0;
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
package com.mailserver.imap;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
/**
* Mailboxes and messages as IMAP sees them. Blocking: the IMAP server calls it from a worker thread,
* never from the network event loop.
*/
public interface ImapStore {
/** A refusal the client sees as "NO reason". */
final class Refused extends RuntimeException {
public Refused(String reason) {
super(reason);
}
}
record Account(long userId, String email) {
}
record Mailbox(long id, String name, boolean system, long uidValidity, long uidNext) {
boolean isInbox() {
return system && "INBOX".equalsIgnoreCase(name);
}
boolean isDrafts() {
return system && "Drafts".equals(name);
}
/** RFC 6154 attribute, or null. */
String specialUse() {
if (!system) {
return null;
}
return switch (name) {
case "Drafts" -> "\\Drafts";
case "Sent" -> "\\Sent";
case "Spam" -> "\\Junk";
case "Trash" -> "\\Trash";
default -> null;
};
}
boolean named(String other) {
return isInbox() ? "INBOX".equalsIgnoreCase(other) : name.toLowerCase(Locale.ROOT).equals(other.toLowerCase(Locale.ROOT));
}
}
record Entry(long id, long uid, boolean seen, boolean flagged, boolean answered, boolean deleted, Instant internalDate) {
Entry withFlags(boolean seen, boolean flagged, boolean answered, boolean deleted) {
return new Entry(id, uid, seen, flagged, answered, deleted, internalDate);
}
}
/** null — wrong user or password. */
Account authenticate(String username, String password);
List<Mailbox> mailboxes(long userId);
Mailbox create(long userId, String name);
void rename(long userId, Mailbox mailbox, String newName);
/** An own folder: its messages go to the Trash (as in the web client), then the folder is removed. */
void delete(long userId, Mailbox mailbox);
/** Messages of the folder in UID order. */
List<Entry> entries(long folderId);
/** The full MIME text of the message (the original one, or built once and kept). */
byte[] source(long messageId);
void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted);
/** Stores the message into the folder; returns its UID. */
long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged, boolean answered,
boolean deleted, Instant internalDate);
/** Copies; returns the new UIDs in the same order. */
List<Long> copy(long userId, List<Entry> messages, Mailbox target);
/** Moves; returns the new UIDs in the same order. */
List<Long> move(long userId, List<Entry> messages, Mailbox target);
/** Removes the messages for good (with attachment files). */
void expunge(List<Long> messageIds);
}
@@ -0,0 +1,255 @@
package com.mailserver.imap;
import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.ContentDisposition;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.internet.ParameterList;
import jakarta.mail.internet.ParseException;
import java.io.ByteArrayOutputStream;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.Locale;
/** Builds IMAP response bytes: strings, literals, ENVELOPE, BODYSTRUCTURE (RFC 3501 7.4.2). */
final class ImapWriter {
private final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImapWriter text(String text) {
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
out.write(bytes, 0, bytes.length);
return this;
}
ImapWriter sp() {
out.write(' ');
return this;
}
ImapWriter crlf() {
return text("\r\n");
}
ImapWriter literal(byte[] bytes) {
text("{" + bytes.length + "}\r\n");
out.write(bytes, 0, bytes.length);
return this;
}
/** Quoted when it is plain ASCII without CR/LF, a literal otherwise; null → NIL. */
ImapWriter nstring(String value) {
if (value == null) {
return text("NIL");
}
return string(value);
}
ImapWriter string(String value) {
boolean quotable = value.length() < 1024;
for (int i = 0; i < value.length() && quotable; i++) {
char c = value.charAt(i);
if (c > 0x7e || c < 0x20) {
quotable = false;
}
}
if (!quotable) {
return literal(value.getBytes(StandardCharsets.UTF_8));
}
return text("\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"");
}
byte[] bytes() {
return out.toByteArray();
}
// ---------- ENVELOPE ----------
ImapWriter envelope(MimeTree message) {
text("(");
nstring(message.header("Date")).sp();
nstring(message.header("Subject")).sp();
String from = message.header("From");
addresses(from).sp();
addresses(firstNonNull(message.header("Sender"), from)).sp();
addresses(firstNonNull(message.header("Reply-To"), from)).sp();
addresses(message.header("To")).sp();
addresses(message.header("Cc")).sp();
addresses(message.header("Bcc")).sp();
nstring(message.header("In-Reply-To")).sp();
nstring(message.header("Message-ID"));
return text(")");
}
private ImapWriter addresses(String header) {
if (header == null || header.isBlank()) {
return text("NIL");
}
InternetAddress[] list;
try {
list = InternetAddress.parseHeader(header, false);
} catch (AddressException e) {
return text("NIL");
}
if (list.length == 0) {
return text("NIL");
}
text("(");
for (InternetAddress address : list) {
String email = address.getAddress() == null ? "" : address.getAddress();
int at = email.lastIndexOf('@');
text("(");
nstring(encodedPersonal(address.getPersonal())).sp().text("NIL").sp();
nstring(at > 0 ? email.substring(0, at) : email).sp();
nstring(at > 0 ? email.substring(at + 1) : null);
text(")");
}
return text(")");
}
private static String encodedPersonal(String personal) {
if (personal == null) {
return null;
}
try {
return MimeUtility.encodeWord(personal, "UTF-8", "B");
} catch (UnsupportedEncodingException e) {
return personal;
}
}
// ---------- BODYSTRUCTURE / BODY ----------
ImapWriter bodyStructure(MimeTree part, boolean extensible) {
text("(");
if (part.isMultipart() && !part.children.isEmpty()) {
for (MimeTree child : part.children) {
bodyStructure(child, extensible);
}
sp().string(part.type.getSubType().toUpperCase(Locale.ROOT));
if (extensible) {
sp().params(part.type.getParameterList()).sp();
disposition(part).sp().text("NIL NIL");
}
return text(")");
}
String primary = part.isMultipart() ? "TEXT" : part.type.getPrimaryType().toUpperCase(Locale.ROOT);
String sub = part.isMultipart() ? "PLAIN" : part.type.getSubType().toUpperCase(Locale.ROOT);
string(primary).sp().string(sub).sp();
params(part.type.getParameterList()).sp();
nstring(part.header("Content-ID")).sp();
nstring(part.header("Content-Description")).sp();
String encoding = part.header("Content-Transfer-Encoding");
string(encoding == null ? "7BIT" : encoding.toUpperCase(Locale.ROOT)).sp();
text(String.valueOf(part.bodySize()));
if ("TEXT".equals(primary)) {
sp().text(String.valueOf(part.bodyLines()));
} else if (part.message != null) {
sp().envelope(part.message).sp().bodyStructure(part.message, extensible).sp().text(String.valueOf(part.bodyLines()));
}
if (extensible) {
sp().nstring(part.header("Content-MD5")).sp();
disposition(part).sp();
nstring(part.header("Content-Language")).sp();
nstring(part.header("Content-Location"));
}
return text(")");
}
private ImapWriter params(ParameterList params) {
if (params == null || params.size() == 0) {
return text("NIL");
}
text("(");
boolean first = true;
Enumeration<String> names = params.getNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
if (!first) {
sp();
}
first = false;
string(name.toUpperCase(Locale.ROOT)).sp().string(String.valueOf(params.get(name)));
}
return text(")");
}
private ImapWriter disposition(MimeTree part) {
String header = part.header("Content-Disposition");
if (header == null) {
return text("NIL");
}
try {
ContentDisposition disposition = new ContentDisposition(header);
text("(").string(disposition.getDisposition().toUpperCase(Locale.ROOT)).sp();
params(disposition.getParameterList());
return text(")");
} catch (ParseException e) {
return text("NIL");
}
}
private static String firstNonNull(String a, String b) {
return a != null ? a : b;
}
// ---------- modified UTF-7 (RFC 3501 5.1.3) for mailbox names ----------
static String encodeMailbox(String name) {
StringBuilder out = new StringBuilder();
StringBuilder pending = new StringBuilder();
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (c >= 0x20 && c <= 0x7e) {
flushBase64(out, pending);
out.append(c == '&' ? "&-" : String.valueOf(c));
} else {
pending.append(c);
}
}
flushBase64(out, pending);
return out.toString();
}
private static void flushBase64(StringBuilder out, StringBuilder pending) {
if (pending.length() == 0) {
return;
}
byte[] utf16 = pending.toString().getBytes(StandardCharsets.UTF_16BE);
String base64 = java.util.Base64.getEncoder().withoutPadding().encodeToString(utf16).replace('/', ',');
out.append('&').append(base64).append('-');
pending.setLength(0);
}
static String decodeMailbox(String name) {
StringBuilder out = new StringBuilder();
int i = 0;
while (i < name.length()) {
char c = name.charAt(i);
if (c != '&') {
out.append(c);
i++;
continue;
}
int dash = name.indexOf('-', i);
if (dash < 0) {
out.append(name.substring(i));
break;
}
if (dash == i + 1) {
out.append('&');
} else {
try {
byte[] utf16 = java.util.Base64.getDecoder().decode(name.substring(i + 1, dash).replace(',', '/'));
out.append(new String(utf16, StandardCharsets.UTF_16BE));
} catch (IllegalArgumentException e) {
out.append(name, i, dash + 1);
}
}
i = dash + 1;
}
return out.toString();
}
}
@@ -0,0 +1,129 @@
package com.mailserver.imap;
import com.mailserver.domain.Attachment;
import com.mailserver.domain.Message;
import jakarta.activation.DataHandler;
import jakarta.activation.FileDataSource;
import jakarta.mail.MessagingException;
import jakarta.mail.Session;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.internet.MimeUtility;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
/**
* MIME text of a message that has no original (written in the web client): built from the stored fields and
* attachment files. The boundary and Message-ID come from the message id, so the result is the same every time.
*/
public final class MimeSource {
private MimeSource() {
}
public static byte[] build(Message message, List<Attachment> attachments, String hostname)
throws MessagingException, IOException {
Properties props = new Properties();
props.put("mail.mime.splitlongparameters", "true");
Session session = Session.getInstance(props);
String messageId = message.getMessageId() == null || message.getMessageId().isBlank()
? message.getId() + "@" + hostname
: message.getMessageId().replaceAll("^<|>$", "");
if (!messageId.contains("@")) {
messageId = messageId + "@" + hostname;
}
String finalMessageId = "<" + messageId + ">";
MimeMessage mime = new MimeMessage(session) {
@Override
protected void updateMessageID() throws MessagingException {
setHeader("Message-ID", finalMessageId);
}
};
String from = message.getSenderEmail() == null ? "unknown@" + hostname : message.getSenderEmail();
mime.setFrom(new InternetAddress(from, message.getSenderName(), "UTF-8"));
List<InternetAddress> to = addresses(message.getRecipientEmail());
if (!to.isEmpty()) {
mime.setRecipients(jakarta.mail.Message.RecipientType.TO, to.toArray(new InternetAddress[0]));
}
List<InternetAddress> cc = addresses(message.getCcEmail());
if (!cc.isEmpty()) {
mime.setRecipients(jakarta.mail.Message.RecipientType.CC, cc.toArray(new InternetAddress[0]));
}
mime.setSubject(message.getSubject() == null ? "" : message.getSubject(), "UTF-8");
if (message.getReceivedAt() != null) {
mime.setSentDate(Date.from(message.getReceivedAt()));
}
String body = message.getBody() == null ? "" : message.getBody();
String subtype = message.isHtml() ? "html" : "plain";
if (attachments.isEmpty()) {
mime.setText(body, "UTF-8", subtype);
} else {
MimeMultipart mixed = new FixedBoundaryMultipart("mixed", "=_rms_" + message.getId());
MimeBodyPart text = new MimeBodyPart();
text.setText(body, "UTF-8", subtype);
mixed.addBodyPart(text);
for (Attachment attachment : attachments) {
Path file = Path.of(attachment.getFilePath());
if (!Files.isRegularFile(file)) {
continue;
}
MimeBodyPart part = new MimeBodyPart();
FileDataSource source = new FileDataSource(file.toFile()) {
@Override
public String getContentType() {
return attachment.getContentType() == null ? "application/octet-stream" : attachment.getContentType();
}
};
part.setDataHandler(new DataHandler(source));
part.setFileName(MimeUtility.encodeText(attachment.getFilename(), "UTF-8", null));
part.setDisposition(attachment.getContentId() != null ? MimeBodyPart.INLINE : MimeBodyPart.ATTACHMENT);
if (attachment.getContentId() != null) {
part.setContentID("<" + attachment.getContentId().replaceAll("^<|>$", "") + ">");
}
mixed.addBodyPart(part);
}
mime.setContent(mixed);
}
mime.saveChanges();
ByteArrayOutputStream out = new ByteArrayOutputStream();
mime.writeTo(out);
return out.toByteArray();
}
private static List<InternetAddress> addresses(String list) {
List<InternetAddress> result = new ArrayList<>();
if (list == null || list.isBlank()) {
return result;
}
for (String one : list.split("[,;]")) {
String address = one.trim();
if (!address.isEmpty()) {
try {
result.add(new InternetAddress(address));
} catch (MessagingException ignored) {
// a broken address is left out, the message is still served
}
}
}
return result;
}
/** MimeMultipart with a given boundary instead of a random one. */
private static final class FixedBoundaryMultipart extends MimeMultipart {
FixedBoundaryMultipart(String subtype, String boundary) {
super(subtype);
this.contentType = "multipart/" + subtype + "; boundary=\"" + boundary + "\"";
}
}
}
@@ -0,0 +1,250 @@
package com.mailserver.imap;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.InternetHeaders;
import jakarta.mail.internet.ParseException;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* A message split into MIME parts by byte offsets — what IMAP needs for BODYSTRUCTURE and BODY[section]:
* every part is a range of the original bytes, so sizes and contents match BODY[] exactly.
*/
final class MimeTree {
private static final int MAX_DEPTH = 20;
final byte[] raw;
/** Part = [start, end); headers = [start, bodyStart), the blank line included. */
final int start;
final int bodyStart;
final int end;
final InternetHeaders headers;
final ContentType type;
final List<MimeTree> children;
/** The encapsulated message of a message/rfc822 part. */
final MimeTree message;
private MimeTree(byte[] raw, int start, int end, int depth) {
this.raw = raw;
this.start = start;
this.end = end;
this.bodyStart = headerEnd(raw, start, end);
this.headers = parseHeaders(raw, start, bodyStart);
this.type = contentType(headers);
List<MimeTree> parts = new ArrayList<>();
MimeTree nested = null;
if (depth < MAX_DEPTH) {
if (isMultipart()) {
String boundary = type.getParameter("boundary");
if (boundary != null && !boundary.isEmpty()) {
split(boundary, depth, parts);
}
} else if (is("message", "rfc822") || is("message", "global")) {
nested = new MimeTree(raw, bodyStart, end, depth + 1);
}
}
this.children = Collections.unmodifiableList(parts);
this.message = nested;
}
static MimeTree parse(byte[] raw) {
return new MimeTree(raw, 0, raw.length, 0);
}
boolean isMultipart() {
return "multipart".equalsIgnoreCase(type.getPrimaryType());
}
boolean is(String primary, String sub) {
return primary.equalsIgnoreCase(type.getPrimaryType()) && sub.equalsIgnoreCase(type.getSubType());
}
byte[] all() {
return slice(start, end);
}
byte[] header() {
return slice(start, bodyStart);
}
byte[] body() {
return slice(bodyStart, end);
}
int bodySize() {
return end - bodyStart;
}
/** Lines of the body (for text parts in BODYSTRUCTURE). */
int bodyLines() {
int lines = 0;
for (int i = bodyStart; i < end; i++) {
if (raw[i] == '\n') {
lines++;
}
}
if (end > bodyStart && raw[end - 1] != '\n') {
lines++;
}
return lines;
}
/** Header value unfolded, or null. */
String header(String name) {
String[] values = headers.getHeader(name);
if (values == null || values.length == 0 || values[0] == null) {
return null;
}
return values[0].replaceAll("\r?\n(?=[ \t])", "").trim();
}
/** Only these header fields (or all but these), each with its folded lines, plus the blank line. */
byte[] headerFields(Set<String> names, boolean not) {
StringBuilder out = new StringBuilder();
String text = new String(raw, start, bodyStart - start, StandardCharsets.ISO_8859_1);
String[] lines = text.split("\r?\n", -1);
boolean keep = false;
for (String line : lines) {
if (line.isEmpty()) {
continue;
}
if (line.charAt(0) == ' ' || line.charAt(0) == '\t') {
if (keep) {
out.append(line).append("\r\n");
}
continue;
}
int colon = line.indexOf(':');
String name = colon > 0 ? line.substring(0, colon).trim().toUpperCase(Locale.ROOT) : "";
keep = names.contains(name) != not;
if (keep) {
out.append(line).append("\r\n");
}
}
out.append("\r\n");
return out.toString().getBytes(StandardCharsets.ISO_8859_1);
}
private byte[] slice(int from, int to) {
byte[] copy = new byte[Math.max(0, to - from)];
System.arraycopy(raw, from, copy, 0, copy.length);
return copy;
}
/** Body parts between "--boundary" lines; the CRLF before a delimiter belongs to the delimiter. */
private void split(String boundary, int depth, List<MimeTree> parts) {
byte[] delimiter = ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1);
int partStart = -1;
int i = bodyStart;
while (i < end) {
int lineEnd = indexOf(raw, (byte) '\n', i, end);
int next = lineEnd < 0 ? end : lineEnd + 1;
if (startsWith(raw, i, end, delimiter)) {
int after = i + delimiter.length;
boolean close = after + 1 < end && raw[after] == '-' && raw[after + 1] == '-';
if (partStart >= 0) {
int partEnd = i;
if (partEnd > partStart && raw[partEnd - 1] == '\n') {
partEnd--;
if (partEnd > partStart && raw[partEnd - 1] == '\r') {
partEnd--;
}
}
parts.add(new MimeTree(raw, partStart, Math.max(partStart, partEnd), depth + 1));
}
if (close) {
return;
}
partStart = next;
}
i = next;
}
if (partStart >= 0 && partStart < end) { // no closing delimiter — take the rest
parts.add(new MimeTree(raw, partStart, end, depth + 1));
}
}
private static int headerEnd(byte[] raw, int start, int end) {
if (start < end && raw[start] == '\n') {
return start + 1;
}
if (start + 1 < end && raw[start] == '\r' && raw[start + 1] == '\n') {
return start + 2;
}
for (int i = start; i < end; i++) {
if (raw[i] == '\n') {
if (i + 1 < end && raw[i + 1] == '\n') {
return i + 2;
}
if (i + 2 < end && raw[i + 1] == '\r' && raw[i + 2] == '\n') {
return i + 3;
}
}
}
return end; // headers only
}
private static InternetHeaders parseHeaders(byte[] raw, int start, int end) {
try {
return new InternetHeaders(new ByteArrayInputStream(raw, start, end - start));
} catch (MessagingException e) {
return new InternetHeaders();
}
}
private static ContentType contentType(InternetHeaders headers) {
String[] values = headers.getHeader("Content-Type");
if (values != null && values.length > 0 && values[0] != null) {
try {
return new ContentType(values[0].replaceAll("\r?\n(?=[ \t])", "").trim());
} catch (ParseException ignored) {
// broken header — the RFC 2045 default
}
}
try {
return new ContentType("text/plain; charset=us-ascii");
} catch (ParseException e) {
throw new IllegalStateException(e);
}
}
private static boolean startsWith(byte[] raw, int at, int end, byte[] prefix) {
if (at + prefix.length > end) {
return false;
}
for (int i = 0; i < prefix.length; i++) {
if (raw[at + i] != prefix[i]) {
return false;
}
}
return true;
}
private static int indexOf(byte[] raw, byte value, int from, int end) {
for (int i = from; i < end; i++) {
if (raw[i] == value) {
return i;
}
}
return -1;
}
/** Header names of the part, for tests and debugging. */
List<String> headerNames() {
List<String> names = new ArrayList<>();
Enumeration<jakarta.mail.Header> all = headers.getAllHeaders();
while (all.hasMoreElements()) {
names.add(all.nextElement().getName());
}
return names;
}
}
@@ -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);
@@ -15,10 +15,6 @@ public interface MessageRepository extends R2dbcRepository<Message, Long> {
Flux<Message> findByFolderId(Long folderId); Flux<Message> findByFolderId(Long folderId);
/** IMAP (switched off): the whole folder, newest first. */
@Query("SELECT * FROM messages WHERE folder_id = :#{#folderId} ORDER BY received_at DESC")
Flux<Message> findByFolderId(Long folderId, Pageable pageable);
@Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}") @Query("SELECT COUNT(*) FROM messages WHERE folder_id = :#{#folderId}")
Mono<Long> countByFolderId(Long folderId); Mono<Long> countByFolderId(Long folderId);
@@ -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,257 @@
package com.mailserver.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Brute-force protection for every place that checks a password: SMTP AUTH, IMAP LOGIN/AUTHENTICATE, web login,
* password change. One instance is shared by all of them, so failures on SMTP and IMAP add up.
* <ul>
* <li><b>By IP:</b> {@code maxPerIp} failures within {@code window} → the IP is blocked for {@code block};
* each next block of the same IP is twice as long, up to {@code maxBlock}.</li>
* <li><b>By mailbox:</b> {@code maxPerAccount} failures within {@code window} from any IPs (a distributed attack on
* one mailbox) → the mailbox is blocked for {@code block} — but not for IPs that logged in to it successfully
* before ({@code knownIpTtl}), so an attacker cannot lock the owner's own mail program out.</li>
* </ul>
* While blocked the password is not checked at all (no answer to a guess) and the caller gets {@link #retryAfter}.
* State is in memory: a restart forgets it, which is fine for minutes-long blocks.
*/
public class LoginGuard {
private static final Logger log = LoggerFactory.getLogger(LoginGuard.class);
/** Upper bound of tracked keys of each kind, so a flood of random usernames/IPs cannot eat the memory. */
static final int MAX_KEYS = 100_000;
private static final int MAX_KNOWN_IPS_PER_ACCOUNT = 20;
public record Settings(boolean enabled, int maxPerIp, int maxPerAccount, Duration window, Duration block,
Duration maxBlock, Duration knownIpTtl, Set<String> trustedIps, Duration failureDelay) {
public static Settings defaults() {
return new Settings(true, 10, 20, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24),
Duration.ofDays(30), Set.of(), Duration.ofSeconds(1));
}
}
/** Failures in the window, current block and how many times the key was blocked in a row. */
private static final class Counter {
final Deque<Instant> failures = new ArrayDeque<>();
volatile Instant blockedUntil = Instant.EPOCH;
int strikes;
Instant lastSeen = Instant.EPOCH;
}
private final Settings settings;
private final Clock clock;
private final Map<String, Counter> byIp = new ConcurrentHashMap<>();
private final Map<String, Counter> byAccount = new ConcurrentHashMap<>();
/** account → IPs that logged in to it successfully, with the time of the last success. */
private final Map<String, Map<String, Instant>> knownIps = new ConcurrentHashMap<>();
public LoginGuard(Settings settings) {
this(settings, Clock.systemUTC());
}
LoginGuard(Settings settings, Clock clock) {
this.settings = settings;
this.clock = clock;
}
/** Guard that never blocks (tests, closed networks). */
public static LoginGuard disabled() {
Settings d = Settings.defaults();
return new LoginGuard(new Settings(false, d.maxPerIp(), d.maxPerAccount(), d.window(), d.block(), d.maxBlock(),
d.knownIpTtl(), Set.of(), Duration.ZERO));
}
/** Pause before answering "wrong password": slows down guessing inside one connection. */
public Duration failureDelay() {
return settings.enabled() ? settings.failureDelay() : Duration.ZERO;
}
/**
* Zero when the attempt may go on and the password may be checked; otherwise how long the caller has to wait.
*/
public Duration retryAfter(String ip, String account) {
if (!settings.enabled()) {
return Duration.ZERO;
}
Instant now = clock.instant();
Duration wait = Duration.ZERO;
if (!trusted(ip)) {
wait = remaining(byIp.get(ip), now);
}
String acc = normalize(account);
if (acc != null && !known(acc, ip, now)) {
Duration accountWait = remaining(byAccount.get(acc), now);
if (accountWait.compareTo(wait) > 0) {
wait = accountWait;
}
}
return wait;
}
public boolean blocked(String ip, String account) {
return !retryAfter(ip, account).isZero();
}
/** A wrong password (or an unknown mailbox). */
public void failure(String ip, String account) {
if (!settings.enabled()) {
return;
}
Instant now = clock.instant();
if (!trusted(ip)) {
count(byIp, ip, settings.maxPerIp(), now, "IP " + ip);
}
String acc = normalize(account);
if (acc != null) {
count(byAccount, acc, settings.maxPerAccount(), now, "mailbox " + acc);
}
}
/** A right password: the IP becomes known for the mailbox, failures of this IP are forgiven. */
public void success(String ip, String account) {
if (!settings.enabled()) {
return;
}
Instant now = clock.instant();
String acc = normalize(account);
if (acc != null) {
if (knownIps.size() < MAX_KEYS || knownIps.containsKey(acc)) {
knownIps.compute(acc, (k, ips) -> {
Map<String, Instant> map = ips != null ? ips : new LinkedHashMap<>();
synchronized (map) {
map.remove(ip);
map.put(ip, now);
while (map.size() > MAX_KNOWN_IPS_PER_ACCOUNT) {
map.remove(map.keySet().iterator().next());
}
}
return map;
});
}
}
byIp.computeIfPresent(ip, (k, c) -> {
c.failures.clear();
return c.blockedUntil.isAfter(now) ? c : null;
});
}
/** Drops what expired; runs every minute. */
@Scheduled(fixedDelay = 60_000)
public void sweep() {
Instant now = clock.instant();
Instant idle = now.minus(settings.maxBlock()).minus(settings.window());
byIp.entrySet().removeIf(e -> stale(e.getValue(), now, idle));
byAccount.entrySet().removeIf(e -> stale(e.getValue(), now, idle));
Instant knownSince = now.minus(settings.knownIpTtl());
knownIps.entrySet().removeIf(e -> {
synchronized (e.getValue()) {
e.getValue().values().removeIf(t -> t.isBefore(knownSince));
return e.getValue().isEmpty();
}
});
}
int trackedIps() {
return byIp.size();
}
int trackedAccounts() {
return byAccount.size();
}
// ---------------------------------------------------------------------------------------------
private void count(Map<String, Counter> map, String key, int max, Instant now, String what) {
if (map.size() >= MAX_KEYS && !map.containsKey(key)) {
sweep();
if (map.size() >= MAX_KEYS) {
log.warn("login guard: {} keys tracked, {} not counted", map.size(), what);
return;
}
}
map.compute(key, (k, existing) -> {
Counter c = existing != null ? existing : new Counter();
c.lastSeen = now;
Instant from = now.minus(settings.window());
while (!c.failures.isEmpty() && c.failures.peekFirst().isBefore(from)) {
c.failures.pollFirst();
}
if (c.blockedUntil.isAfter(now)) {
return c; // already blocked: attempts are refused before the password check anyway
}
// A long quiet time after the last block forgives the strikes
if (c.strikes > 0 && c.blockedUntil.plus(settings.maxBlock()).isBefore(now)) {
c.strikes = 0;
}
c.failures.addLast(now);
if (c.failures.size() >= max) {
Duration block = settings.block().multipliedBy(1L << Math.min(c.strikes, 20));
if (block.compareTo(settings.maxBlock()) > 0) {
block = settings.maxBlock();
}
c.strikes++;
c.blockedUntil = now.plus(block);
c.failures.clear();
log.warn("login guard: {} blocked for {} after {} failed logins (block #{})",
what, human(block), max, c.strikes);
}
return c;
});
}
private static Duration remaining(Counter c, Instant now) {
if (c == null) {
return Duration.ZERO;
}
Instant until = c.blockedUntil;
return until.isAfter(now) ? Duration.between(now, until) : Duration.ZERO;
}
private boolean known(String account, String ip, Instant now) {
Map<String, Instant> ips = knownIps.get(account);
if (ips == null) {
return false;
}
synchronized (ips) {
Instant at = ips.get(ip);
return at != null && at.plus(settings.knownIpTtl()).isAfter(now);
}
}
private boolean trusted(String ip) {
return settings.trustedIps().contains(ip);
}
private static boolean stale(Counter c, Instant now, Instant idle) {
return !c.blockedUntil.isAfter(now) && c.lastSeen.isBefore(idle);
}
private static String normalize(String account) {
if (account == null) {
return null;
}
String acc = account.trim().toLowerCase(Locale.ROOT);
return acc.isEmpty() ? null : acc;
}
/** "15 min" / "2 h" for messages to the user. */
public static String human(Duration d) {
long minutes = Math.max(1, (d.toSeconds() + 59) / 60);
return minutes < 120 ? minutes + " min" : (minutes + 59) / 60 + " h";
}
}
@@ -38,9 +38,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
private final SpfService spfService; private final SpfService spfService;
private final DmarcService dmarcService; private final DmarcService dmarcService;
private final String hostname; private final String hostname;
private final MessageSources sources;
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService, public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) { DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
MessageSources sources) {
this.sources = sources;
this.mailService = mailService; this.mailService = mailService;
this.dkimService = dkimService; this.dkimService = dkimService;
this.spfService = spfService; this.spfService = spfService;
@@ -51,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;
@@ -77,16 +86,18 @@ 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());
} }
// IMAP serves the message exactly as it arrived
sources.put(stored.getId(), raw).block();
} }
} }
/** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */ /** "a@x, b@y" from a header, or null. A broken header is ignored, not a reason to lose the message. */
static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) { public static String addresses(MimeMessage mime, jakarta.mail.Message.RecipientType type) {
try { try {
jakarta.mail.Address[] list = mime.getRecipients(type); jakarta.mail.Address[] list = mime.getRecipients(type);
if (list == null || list.length == 0) { if (list == null || list.length == 0) {
@@ -103,11 +114,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
} }
} }
record FilePart(String name, String contentType, byte[] bytes) { public record FilePart(String name, String contentType, byte[] bytes) {
} }
/** Parts with a file name (attachments and inline images), nested multiparts walked. */ /** Parts with a file name (attachments and inline images), nested multiparts walked. */
static List<FilePart> attachments(Part part) throws MessagingException, IOException { public static List<FilePart> attachments(Part part) throws MessagingException, IOException {
List<FilePart> files = new ArrayList<>(); List<FilePart> files = new ArrayList<>();
collect(part, files); collect(part, files);
return files; return files;
@@ -144,11 +155,11 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
} }
} }
record Body(String text, boolean html) { public record Body(String text, boolean html) {
} }
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */ /** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
static Body body(Part part) throws MessagingException, IOException { public static Body body(Part part) throws MessagingException, IOException {
String html = find(part, "text/html"); String html = find(part, "text/html");
if (html != null) { if (html != null) {
return new Body(html, true); return new Body(html, true);
@@ -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));
}
}
@@ -59,7 +59,8 @@ public class MailService {
public MailService(MessageRepository messageRepository, FolderRepository folderRepository, public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
UserRepository userRepository, AttachmentRepository attachmentRepository, UserRepository userRepository, AttachmentRepository attachmentRepository,
ContactRepository contactRepository, ContactRepository contactRepository,
SpamFilterService spamFilterService) { SpamFilterService spamFilterService, MessageSources sources) {
this.sources = sources;
this.messageRepository = messageRepository; this.messageRepository = messageRepository;
this.folderRepository = folderRepository; this.folderRepository = folderRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
@@ -69,6 +70,8 @@ public class MailService {
} }
/** System folders in the order the web client shows them; the rest — own folders, by name. */ /** System folders in the order the web client shows them; the rest — own folders, by name. */
private final MessageSources sources;
public static final List<String> SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash"); public static final List<String> SYSTEM_FOLDERS = List.of("INBOX", "Drafts", "Sent", "Spam", "Trash");
/** Messages per folder in one list — the web client sorts and pages them itself. */ /** Messages per folder in one list — the web client sorts and pages them itself. */
@@ -159,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")));
@@ -212,6 +243,8 @@ public class MailService {
message.setMessageSize((long) (subject.length() + body.length())); message.setMessageSize((long) (subject.length() + body.length()));
return messageRepository.save(message); return messageRepository.save(message);
}) })
// IMAP: the draft changed — its MIME text is built anew
.flatMap(saved -> sources.remove(saved.getId()).thenReturn(saved))
.flatMap(this::withAttachmentCount); .flatMap(this::withAttachmentCount);
} }
@@ -226,6 +259,7 @@ public class MailService {
/** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */ /** After sending: the draft's attachments move to the copy in Sent, the draft itself is removed. */
public Mono<Void> finishDraft(Long draftId, Long sentMessageId) { public Mono<Void> finishDraft(Long draftId, Long sentMessageId) {
return attachmentRepository.moveAll(draftId, sentMessageId) return attachmentRepository.moveAll(draftId, sentMessageId)
.then(sources.remove(sentMessageId))
.then(messageRepository.deleteById(draftId)); .then(messageRepository.deleteById(draftId));
} }
@@ -247,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());
@@ -354,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));
@@ -433,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;
} }
@@ -467,7 +525,7 @@ public class MailService {
attachment.setContentType(contentType); attachment.setContentType(contentType);
attachment.setSizeBytes(sizeBytes); attachment.setSizeBytes(sizeBytes);
attachment.setFilePath(filePath); attachment.setFilePath(filePath);
return attachmentRepository.save(attachment); return sources.remove(messageId).then(attachmentRepository.save(attachment));
}) })
.map(this::toAttachmentResponse); .map(this::toAttachmentResponse);
} }
@@ -491,7 +549,7 @@ public class MailService {
return ownedAttachment(attachmentId, userId) return ownedAttachment(attachmentId, userId)
.flatMap(attachment -> { .flatMap(attachment -> {
deleteFile(attachment.getFilePath()); deleteFile(attachment.getFilePath());
return attachmentRepository.delete(attachment); return sources.remove(attachment.getMessageId()).then(attachmentRepository.delete(attachment));
}); });
} }
@@ -0,0 +1,129 @@
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;
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) {
this.db = db;
this.users = users;
this.userService = userService;
}
/**
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → 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)) {
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();
}
}
@@ -0,0 +1,38 @@
package com.mailserver.service;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
/**
* The message exactly as received or appended (table message_sources). IMAP serves it as BODY[]; a message without
* one gets its MIME text built once ({@link com.mailserver.imap.MimeSource}). A changed draft loses it.
*/
@Component
public class MessageSources {
private final DatabaseClient db;
public MessageSources(DatabaseClient db) {
this.db = db;
}
public Mono<byte[]> get(Long messageId) {
return db.sql("SELECT raw FROM message_sources WHERE message_id = :id")
.bind("id", messageId)
.map(row -> row.get("raw", byte[].class))
.one();
}
public Mono<Void> put(Long messageId, byte[] raw) {
return db.sql("INSERT INTO message_sources (message_id, raw) VALUES (:id, :raw) "
+ "ON CONFLICT (message_id) DO UPDATE SET raw = EXCLUDED.raw")
.bind("id", messageId)
.bind("raw", raw)
.then();
}
public Mono<Void> remove(Long messageId) {
return db.sql("DELETE FROM message_sources WHERE message_id = :id").bind("id", messageId).then();
}
}
@@ -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,45 @@ 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() {
return userRepository.findAll();
}
public Mono<User> byId(Long id) {
return userRepository.findById(id);
}
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
public Mono<User> setPassword(User user, String newPassword) {
user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user);
}
public Mono<User> setActive(User user, boolean active) {
user.setActive(active);
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> 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,15 +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:
* our domain → our mailbox (or "no such user"); catch-all set → everything into that mailbox; * <ul>
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay). * <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
* <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 {
@@ -27,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;
@@ -37,22 +52,35 @@ 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)) {
String base = MailDomains.baseOf(recipient);
if (mailboxExists(recipient) || mailboxExists(base)) {
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;
} }
if (domains.isLocal(recipient)) { if (domains.isLocal(recipient)) {
return Boolean.TRUE.equals(users.existsByEmail(recipient.toLowerCase(Locale.ROOT)).block()) return Route.UNKNOWN_USER;
? Route.LOCAL : Route.UNKNOWN_USER;
} }
if (authenticatedUser == null) { if (authenticatedUser == null) {
return Route.RELAY_DENIED; return Route.RELAY_DENIED;
@@ -63,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 = domains.catchAll() != null ? domains.catchAll() : 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);
@@ -79,5 +107,59 @@ 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 + folder (null — INBOX) that store mail to a local address. */
record Target(String mailbox, String folder) {
}
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) {
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
} }
} }
@@ -0,0 +1,194 @@
package com.mailserver.smtp;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.X509ExtendedKeyManager;
import java.io.InputStream;
import java.io.Reader;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Server certificate and key from PEM files, read again when the files change — a Let's Encrypt certificate
* renewed on disk (lego, certbot) is picked up without a restart, for STARTTLS and implicit TLS alike: the JDK
* asks the key manager on every handshake. The files are checked at most once per {@code interval}; a file
* that cannot be read (a renewal half written) keeps the previous certificate and is tried again next time.
* <p>
* Key: PKCS#8 ({@code PRIVATE KEY}) or the traditional {@code RSA PRIVATE KEY} / {@code EC PRIVATE KEY} that
* lego and certbot write. Each loaded pair gets its own alias, so a handshake that asks for the chain and then
* for the key never mixes two generations.
*/
public final class ReloadingKeyManager extends X509ExtendedKeyManager {
private static final Logger log = LoggerFactory.getLogger(ReloadingKeyManager.class);
private static final String ALIAS_PREFIX = "mail-";
record Material(String alias, X509Certificate[] chain, PrivateKey key, FileTime certTime, FileTime keyTime) {
}
private final Path certFile;
private final Path keyFile;
private final Duration interval;
private final Clock clock;
/** The current pair and the one before it (a handshake may still hold its alias) */
private final Map<String, Material> byAlias = new ConcurrentHashMap<>();
private volatile Material current;
private volatile long checkedAt;
private int generation;
public ReloadingKeyManager(Path certFile, Path keyFile, Duration interval, Clock clock) throws Exception {
this.certFile = certFile;
this.keyFile = keyFile;
this.interval = interval;
this.clock = clock;
install(load());
this.checkedAt = clock.millis();
}
/** The pair to use now; reads the files again when the interval has passed and they changed */
Material material() {
long now = clock.millis();
if (now - checkedAt >= interval.toMillis()) {
synchronized (this) {
if (now - checkedAt >= interval.toMillis()) {
checkedAt = now;
reloadIfChanged();
}
}
}
return current;
}
private void reloadIfChanged() {
Material old = current;
try {
if (Files.getLastModifiedTime(certFile).equals(old.certTime())
&& Files.getLastModifiedTime(keyFile).equals(old.keyTime())) {
return;
}
Material fresh = load();
install(fresh);
log.info("TLS: certificate {} reloaded, valid until {}", certFile, fresh.chain()[0].getNotAfter());
} catch (Exception e) {
log.warn("TLS: cannot read {} / {} ({}), keeping the certificate valid until {}",
certFile, keyFile, e.toString(), old.chain()[0].getNotAfter());
}
}
private synchronized void install(Material material) {
Material previous = current;
byAlias.keySet().removeIf(alias -> previous == null || !alias.equals(previous.alias()));
byAlias.put(material.alias(), material);
current = material;
}
private Material load() throws Exception {
FileTime certTime = Files.getLastModifiedTime(certFile);
FileTime keyTime = Files.getLastModifiedTime(keyFile);
X509Certificate[] chain;
try (InputStream in = Files.newInputStream(certFile)) {
chain = CertificateFactory.getInstance("X.509").generateCertificates(in).stream()
.map(X509Certificate.class::cast).toArray(X509Certificate[]::new);
}
if (chain.length == 0) {
throw new IllegalStateException("no certificate in " + certFile);
}
PrivateKey key = readKey(keyFile);
if (!chain[0].getPublicKey().getAlgorithm().equals(key.getAlgorithm())) {
throw new IllegalStateException("the key in " + keyFile + " is " + key.getAlgorithm()
+ ", the certificate is " + chain[0].getPublicKey().getAlgorithm());
}
String alias;
synchronized (this) {
alias = ALIAS_PREFIX + (++generation);
}
return new Material(alias, chain, key, certTime, keyTime);
}
static PrivateKey readKey(Path file) throws Exception {
try (Reader reader = Files.newBufferedReader(file, StandardCharsets.US_ASCII);
PEMParser parser = new PEMParser(reader)) {
Object object;
while ((object = parser.readObject()) != null) {
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
if (object instanceof PrivateKeyInfo info) {
return converter.getPrivateKey(info);
}
if (object instanceof PEMKeyPair pair) {
return converter.getPrivateKey(pair.getPrivateKeyInfo()); // SEC1 may come without the public part
}
// EC PARAMETERS before EC PRIVATE KEY (openssl ecparam -genkey) — skip
}
}
throw new IllegalStateException("no private key in " + file);
}
private String serverAlias(String keyType) {
Material material = material();
String algorithm = material.key().getAlgorithm();
boolean fits = keyType == null || keyType.equalsIgnoreCase(algorithm)
|| ("RSA".equals(algorithm) && keyType.equalsIgnoreCase("RSASSA-PSS"))
|| ("EC".equals(algorithm) && keyType.toUpperCase().startsWith("EC"));
return fits ? material.alias() : null;
}
@Override
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
return serverAlias(keyType);
}
@Override
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
return serverAlias(keyType);
}
@Override
public String[] getServerAliases(String keyType, Principal[] issuers) {
String alias = serverAlias(keyType);
return alias == null ? null : new String[]{alias};
}
@Override
public X509Certificate[] getCertificateChain(String alias) {
Material material = alias == null ? null : byAlias.get(alias);
return material == null ? null : material.chain().clone();
}
@Override
public PrivateKey getPrivateKey(String alias) {
Material material = alias == null ? null : byAlias.get(alias);
return material == null ? null : material.key();
}
@Override
public String[] getClientAliases(String keyType, Principal[] issuers) {
return null;
}
@Override
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
return null;
}
@Override
public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) {
return null;
}
}
@@ -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);
@@ -1,13 +1,23 @@
package com.mailserver.smtp; package com.mailserver.smtp;
import com.mailserver.security.LoginGuard;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.LineBasedFrameDecoder; import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.MessageToMessageDecoder;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutHandler; import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import reactor.netty.Connection;
import reactor.netty.DisposableServer; import reactor.netty.DisposableServer;
import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer; import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -32,7 +42,10 @@ import java.util.concurrent.TimeUnit;
* <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li> * <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li>
* <li>database and DNS work runs on a worker thread, not on the event loop.</li> * <li>database and DNS work runs on a worker thread, not on the event loop.</li>
* </ul> * </ul>
* No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network). * TLS (RFC 3207 STARTTLS, or implicit TLS on a separate port, RFC 8314): with a {@code tls} context STARTTLS is
* offered; AUTH only over TLS, or without it when {@code authWithoutTls} is on (a closed network: VM, docker network).
* After STARTTLS the session starts over (new EHLO), and lines sent in plain text together with STARTTLS are not
* executed — the connection is closed (command injection, CVE-2011-0411).
*/ */
public class SmtpServer { public class SmtpServer {
@@ -42,47 +55,116 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024; static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100; static final int MAX_RECIPIENTS = 100;
private static final int MAX_BAD_COMMANDS = 20; private static final int MAX_BAD_COMMANDS = 20;
/** Wrong passwords in one connection before it is closed. */
private static final int MAX_AUTH_FAILURES = 3;
// Compared by identity: TLS starts right after exactly this reply is written
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
/**
* @param tls certificate for STARTTLS / implicit TLS; null — no TLS
* @param implicitTls TLS from the first byte (port 465), no STARTTLS
*/
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls, public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout, SslContext tls, boolean implicitTls) {
public Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) { Duration idleTimeout) {
this(host, port, hostname, maxMessageBytes, authWithoutTls, idleTimeout, null, false);
}
}
/** A line of input and whether it came over TLS. */
record Line(byte[] bytes, boolean tls) {
}
/** Marks each line: decoded behind an SslHandler (encrypted) or not. */
private static final class LineTagger extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List<Object> out) {
out.add(new Line(ByteBufUtil.getBytes(frame), ctx.pipeline().get(SslHandler.class) != null));
}
} }
private final Settings settings; private final Settings settings;
private final SmtpBackend backend; private final SmtpBackend backend;
private final LoginGuard guard;
private DisposableServer server; private DisposableServer server;
public SmtpServer(Settings settings, SmtpBackend backend) { public SmtpServer(Settings settings, SmtpBackend backend) {
this(settings, backend, LoginGuard.disabled());
}
public SmtpServer(Settings settings, SmtpBackend backend, LoginGuard guard) {
this.settings = settings; this.settings = settings;
this.backend = backend; this.backend = backend;
this.guard = guard;
} }
public void start() { public void start() {
server = TcpServer.create() if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host()) .host(settings.host())
.port(settings.port()) .port(settings.port())
.doOnConnection(connection -> connection .doOnConnection(connection -> connection
.addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS)) .addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))) .addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))
.addHandlerLast("smtp-tag", new LineTagger()));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp
.handle((inbound, outbound) -> { .handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"}; String[] remoteIp = {"unknown"};
inbound.withConnection(c -> { inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) { if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress(); remoteIp[0] = address.getAddress().getHostAddress();
} }
}); });
Session session = new Session(remoteIp[0]); Session session = new Session(remoteIp[0], settings.implicitTls());
Flux<String> replies = inbound.receive().asByteArray() Flux<String> replies = inbound.receiveObject().cast(Line.class)
.concatMap(session::onLine) .concatMap(session::onLine)
.onErrorResume(e -> { .onErrorResume(e -> {
log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString()); log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n"); return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n");
}) })
.takeUntil(reply -> session.closing); .takeUntil(reply -> session.closing);
return outbound.sendString(Flux.concat( // One reply at a time, each written before the next line is handled: after "220 Ready to start TLS"
Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII); // the TLS handler goes in only when the reply has left in plain text
return Flux.concat(Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies)
.concatMap(reply -> send(outbound, reply)
.then(Mono.defer(() -> reply == STARTTLS_READY ? startTls(connection[0], session) : Mono.<Void>empty())))
.then();
}) })
.bindNow(Duration.ofSeconds(10)); .bindNow(Duration.ofSeconds(10));
log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname()); log.info("SMTP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private static Mono<Void> send(NettyOutbound outbound, String reply) {
return outbound.sendString(Mono.just(reply), StandardCharsets.US_ASCII).then();
}
/** TLS handler in front, fresh line decoder (no plain-text leftovers mixed into encrypted input). */
private Mono<Void> startTls(Connection connection, Session session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("smtp-lines", "smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false));
pipeline.addFirst("smtp-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (future.isSuccess()) {
log.debug("SMTP {}: TLS {}", session.remoteIp, ssl.engine().getSession().getProtocol());
sink.success();
} else {
log.info("SMTP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
sink.success();
}
}));
} }
public int port() { public int port() {
@@ -99,6 +181,7 @@ public class SmtpServer {
private final class Session { private final class Session {
final String remoteIp; final String remoteIp;
boolean tls;
State state = State.COMMAND; State state = State.COMMAND;
String helo; String helo;
boolean extended; boolean extended;
@@ -109,14 +192,20 @@ public class SmtpServer {
final ByteArrayOutputStream data = new ByteArrayOutputStream(); final ByteArrayOutputStream data = new ByteArrayOutputStream();
boolean dataTooBig; boolean dataTooBig;
int badCommands; int badCommands;
int authFailures;
volatile boolean closing; volatile boolean closing;
Session(String remoteIp) { Session(String remoteIp, boolean tls) {
this.remoteIp = remoteIp; this.remoteIp = remoteIp;
this.tls = tls;
} }
Mono<String> onLine(byte[] raw) { Mono<String> onLine(Line input) {
byte[] line = stripCr(raw); if (tls && !input.tls()) {
// Sent in plain text after STARTTLS, before the handshake: never execute it
return Mono.error(new IllegalStateException("plain-text input after STARTTLS"));
}
byte[] line = stripCr(input.bytes());
return switch (state) { return switch (state) {
case DATA -> onDataLine(line); case DATA -> onDataLine(line);
case AUTH_PLAIN -> authPlain(ascii(line)); case AUTH_PLAIN -> authPlain(ascii(line));
@@ -142,7 +231,7 @@ public class SmtpServer {
} }
case "NOOP" -> reply("250 2.0.0 OK"); case "NOOP" -> reply("250 2.0.0 OK");
case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message"); case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message");
case "STARTTLS" -> reply("454 4.7.0 TLS not available"); case "STARTTLS" -> startTls(arg);
case "QUIT" -> { case "QUIT" -> {
closing = true; closing = true;
yield reply("221 2.0.0 " + settings.hostname() + " Bye"); yield reply("221 2.0.0 " + settings.hostname() + " Bye");
@@ -167,15 +256,41 @@ public class SmtpServer {
sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n"); sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n");
sb.append("250-8BITMIME\r\n"); sb.append("250-8BITMIME\r\n");
sb.append("250-PIPELINING\r\n"); sb.append("250-PIPELINING\r\n");
if (settings.authWithoutTls()) { if (settings.tls() != null && !tls) {
sb.append("250-STARTTLS\r\n");
}
if (authAllowed()) {
sb.append("250-AUTH PLAIN LOGIN\r\n"); sb.append("250-AUTH PLAIN LOGIN\r\n");
} }
sb.append("250 ENHANCEDSTATUSCODES\r\n"); sb.append("250 ENHANCEDSTATUSCODES\r\n");
return Mono.just(sb.toString()); return Mono.just(sb.toString());
} }
private boolean authAllowed() {
return tls || settings.authWithoutTls();
}
private Mono<String> startTls(String arg) {
if (settings.tls() == null) {
return reply("454 4.7.0 TLS not available");
}
if (tls) {
return bad("503 5.5.1 TLS already active");
}
if (!arg.isEmpty()) {
return reply("501 5.5.4 Syntax: STARTTLS");
}
// RFC 3207 4.2: everything known about the client is forgotten, a new EHLO is expected
tls = true;
helo = null;
extended = false;
authenticatedUser = null;
resetTransaction();
return Mono.just(STARTTLS_READY);
}
private Mono<String> auth(String arg) { private Mono<String> auth(String arg) {
if (!settings.authWithoutTls()) { if (!authAllowed()) {
return reply("538 5.7.11 Encryption required for requested authentication mechanism"); return reply("538 5.7.11 Encryption required for requested authentication mechanism");
} }
if (helo == null || !extended) { if (helo == null || !extended) {
@@ -251,16 +366,33 @@ public class SmtpServer {
private Mono<String> checkPassword(String user, String password) { private Mono<String> checkPassword(String user, String password) {
String username = user.trim().toLowerCase(Locale.ROOT); String username = user.trim().toLowerCase(Locale.ROOT);
return blocking(() -> backend.authenticate(username, password)) Duration wait = guard.retryAfter(remoteIp, username);
.map(ok -> { if (!wait.isZero()) {
if (ok) { // Blocked: the password is not even checked, the connection is closed
authenticatedUser = username; log.warn("SMTP {}: login as {} refused, blocked for {}", remoteIp, username, LoginGuard.human(wait));
log.info("SMTP {}: authenticated as {}", remoteIp, username); closing = true;
return "235 2.7.0 Authentication successful\r\n"; return reply("421 4.7.0 Too many failed logins, try again in " + LoginGuard.human(wait));
} }
return blocking(() -> java.util.Optional.ofNullable(backend.authenticate(username, password)))
.flatMap(found -> {
if (found.isPresent()) {
guard.success(remoteIp, username);
authenticatedUser = found.get();
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");
}
guard.failure(remoteIp, username);
log.warn("SMTP {}: authentication failed for {}", remoteIp, username); log.warn("SMTP {}: authentication failed for {}", remoteIp, username);
badCommands++; badCommands++;
return "535 5.7.8 Authentication credentials invalid\r\n"; if (++authFailures >= MAX_AUTH_FAILURES) {
closing = true;
}
String answer = closing
? "535 5.7.8 Authentication credentials invalid, closing connection\r\n"
: "535 5.7.8 Authentication credentials invalid\r\n";
Duration delay = guard.failureDelay();
return delay.isZero() ? Mono.just(answer) : Mono.just(answer).delayElement(delay);
}); });
} }
@@ -307,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";
} }
@@ -317,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";
}); });
} }
@@ -369,7 +502,7 @@ public class SmtpServer {
private byte[] withReceivedHeader(byte[] message) { private byte[] withReceivedHeader(byte[] message) {
String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n" String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n"
+ "\tby " + settings.hostname() + " with " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\r\n" + "\tby " + settings.hostname() + " with " + (tls ? "ESMTPS" : "ESMTP") + (authenticatedUser != null ? "A" : "") + ";\r\n"
+ "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n"; + "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n";
byte[] header = received.getBytes(StandardCharsets.US_ASCII); byte[] header = received.getBytes(StandardCharsets.US_ASCII);
byte[] all = new byte[header.length + message.length]; byte[] all = new byte[header.length + message.length];
@@ -0,0 +1,114 @@
package com.mailserver.smtp;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
/**
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
* <ul>
* <li>{@code cert} and {@code key} given (PEM: certificate chain and the key — PKCS#8, RSA or EC, e.g. from Let's Encrypt) —
* used as is and read again when the files change ({@link ReloadingKeyManager}, checked once a minute);</li>
* <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused —
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not
* check certificates, RFC 7435 opportunistic TLS).</li>
* </ul>
*/
public final class SmtpTls {
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
static final String CERT_FILE = "smtp-cert.pem";
static final String KEY_FILE = "smtp-key.pem";
static final Duration RELOAD_CHECK = Duration.ofMinutes(1);
private SmtpTls() {
}
public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception {
boolean givenCert = certFile != null && !certFile.isBlank();
boolean givenKey = keyFile != null && !keyFile.isBlank();
if (givenCert != givenKey) {
throw new IllegalStateException("TLS: set both the certificate and the key file, or neither");
}
Path cert;
Path key;
if (givenCert) {
cert = Path.of(certFile);
key = Path.of(keyFile);
log.info("SMTP TLS: certificate {}", cert);
} else {
cert = dir.resolve(CERT_FILE);
key = dir.resolve(KEY_FILE);
if (!Files.exists(cert) || !Files.exists(key)) {
selfSigned(hostname, cert, key);
log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir);
} else {
log.info("SMTP TLS: self-signed certificate {}", cert);
}
}
// Read again when the files change (a renewed Let's Encrypt certificate) — no restart needed
return SslContextBuilder.forServer(new ReloadingKeyManager(cert, key, RELOAD_CHECK, Clock.systemUTC()))
.protocols("TLSv1.3", "TLSv1.2")
.build();
}
static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, new SecureRandom());
KeyPair pair = generator.generateKeyPair();
X500Name name = new X500Name("CN=" + hostname);
Instant now = Instant.now();
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))),
Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic());
builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.dNSName, hostname)));
X509Certificate certificate = new JcaX509CertificateConverter()
.getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
Files.createDirectories(keyFile.toAbsolutePath().getParent());
write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true);
write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false);
}
private static String pem(String type, byte[] der) {
return "-----BEGIN " + type + "-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der)
+ "\n-----END " + type + "-----\n";
}
private static void write(Path file, String text, boolean secret) throws IOException {
Files.writeString(file, text, StandardCharsets.US_ASCII);
if (secret) {
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// not a POSIX file system
}
}
}
}
@@ -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()
@@ -21,12 +21,19 @@ spring.flyway.baseline-on-migrate=true
jwt.secret=${JWT_SECRET} jwt.secret=${JWT_SECRET}
# Mailboxes of these domains live here; catch-all: every message to any address goes into this one mailbox (mail trap) # Mailboxes of these domains live here; catch-all: mail to any address without its own mailbox here goes into this one mailbox (mail trap), nothing goes outside
mailserver.domains=${MAIL_DOMAINS:localhost} mailserver.domains=${MAIL_DOMAINS:localhost}
mailserver.catch-all=${MAIL_CATCH_ALL:} mailserver.catch-all=${MAIL_CATCH_ALL:}
mailserver.registration.enabled=${MAIL_REGISTRATION:false} mailserver.registration.enabled=${MAIL_REGISTRATION:false}
mailserver.admin.email=${MAIL_ADMIN_EMAIL:} mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:} mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
# Who manages mailboxes (Settings → Mailboxes), comma separated; empty — the first mailbox (admin.email or admin@<first domain>)
mailserver.admins=${MAIL_ADMINS:}
# 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:}
# 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:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments # Attachments
@@ -39,6 +46,15 @@ mailserver.smtp.port=${SMTP_PORT:3025}
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost} mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false} mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
mailserver.smtp.max-message-bytes=26214400 mailserver.smtp.max-message-bytes=26214400
# TLS (75.1): STARTTLS on the SMTP port; implicit TLS on SMTPS_PORT (465) if set. Certificate: MAIL_TLS_CERT + MAIL_TLS_KEY
# (PEM, PKCS#8 key), otherwise a self-signed one created once in MAIL_TLS_DIR. MAIL_TLS=false — no TLS
mailserver.tls.enabled=${MAIL_TLS:true}
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
mailserver.tls.key-file=${MAIL_TLS_KEY:}
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port)
mailserver.https.port=${HTTPS_PORT:0}
mailserver.smtps.port=${SMTPS_PORT:0}
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
mailserver.outbound.mode=${OUTBOUND_MODE:off} mailserver.outbound.mode=${OUTBOUND_MODE:off}
@@ -48,10 +64,12 @@ mailserver.outbound.username=${OUTBOUND_USERNAME:}
mailserver.outbound.password=${OUTBOUND_PASSWORD:} mailserver.outbound.password=${OUTBOUND_PASSWORD:}
mailserver.outbound.tls=${OUTBOUND_TLS:starttls} mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2) # IMAP (75.2): STARTTLS on IMAP_PORT, implicit TLS on IMAPS_PORT (993) if set; same certificate as SMTP.
mailserver.imap.enabled=false # LOGIN only inside TLS unless IMAP_AUTH_WITHOUT_TLS=true (a closed network only)
mailserver.imap.port=1143 mailserver.imap.enabled=${IMAP_ENABLED:true}
mailserver.imap.hostname=localhost mailserver.imap.port=${IMAP_PORT:3143}
mailserver.imaps.port=${IMAPS_PORT:0}
mailserver.imap.auth-without-tls=${IMAP_AUTH_WITHOUT_TLS:false}
# DKIM — signing of our mail; DNS record printed to the log at start # DKIM — signing of our mail; DNS record printed to the log at start
mailserver.dkim.enabled=${DKIM_ENABLED:true} mailserver.dkim.enabled=${DKIM_ENABLED:true}
@@ -72,3 +90,15 @@ management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never management.endpoint.health.show-details=never
logging.level.com.mailserver=INFO logging.level.com.mailserver=INFO
# Brute-force protection for SMTP AUTH, IMAP login, web login and password change (one counter for all of them):
# MAX_PER_IP wrong passwords from one IP within WINDOW → the IP is blocked for BLOCK (each next block twice as long,
# up to MAX_BLOCK); MAX_PER_ACCOUNT wrong passwords to one mailbox from any IPs → the mailbox is blocked for BLOCK,
# except for IPs that logged in to it before. TRUSTED_IPS (comma-separated) are never blocked by IP
mailserver.login-guard.enabled=${LOGIN_GUARD:true}
mailserver.login-guard.max-per-ip=${LOGIN_GUARD_MAX_PER_IP:10}
mailserver.login-guard.max-per-account=${LOGIN_GUARD_MAX_PER_ACCOUNT:20}
mailserver.login-guard.window=${LOGIN_GUARD_WINDOW:15m}
mailserver.login-guard.block=${LOGIN_GUARD_BLOCK:15m}
mailserver.login-guard.max-block=${LOGIN_GUARD_MAX_BLOCK:24h}
mailserver.login-guard.trusted-ips=${LOGIN_GUARD_TRUSTED_IPS:}
@@ -0,0 +1,43 @@
-- IMAP (75.2): UIDs per folder, IMAP flags, the original message text
-- UIDVALIDITY: never changes for a folder; UIDNEXT: the next UID to give out
ALTER TABLE folders ADD COLUMN uid_validity BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM now())::BIGINT);
ALTER TABLE folders ADD COLUMN uid_next BIGINT NOT NULL DEFAULT 1;
-- UID inside the folder; strictly increasing — a message moved into a folder gets a new, larger UID
ALTER TABLE messages ADD COLUMN uid BIGINT;
ALTER TABLE messages ADD COLUMN is_answered BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE messages ADD COLUMN is_deleted BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE messages m SET uid = numbered.n
FROM (SELECT id, ROW_NUMBER() OVER (PARTITION BY folder_id ORDER BY received_at, id) AS n FROM messages) numbered
WHERE m.id = numbered.id;
UPDATE folders f SET uid_next = COALESCE((SELECT MAX(uid) FROM messages WHERE folder_id = f.id), 0) + 1;
ALTER TABLE messages ALTER COLUMN uid SET NOT NULL;
CREATE UNIQUE INDEX idx_messages_folder_uid ON messages(folder_id, uid);
-- The UID is given out by the database, so every path that stores or moves a message (SMTP, web client, IMAP)
-- gets it right without knowing about IMAP
CREATE FUNCTION assign_message_uid() RETURNS trigger AS $$
BEGIN
UPDATE folders SET uid_next = uid_next + 1 WHERE id = NEW.folder_id RETURNING uid_next - 1 INTO NEW.uid;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER messages_uid_on_insert BEFORE INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION assign_message_uid();
-- The web client saves the whole row (folder_id included) — only a real move gets a new UID
CREATE TRIGGER messages_uid_on_move BEFORE UPDATE OF folder_id ON messages
FOR EACH ROW WHEN (NEW.folder_id IS DISTINCT FROM OLD.folder_id) EXECUTE FUNCTION assign_message_uid();
-- The message exactly as received (SMTP) or appended (IMAP APPEND); for messages without it the server builds
-- the MIME text once from the stored fields and keeps it here, so sizes and bytes never change between fetches.
-- Removed when a draft changes.
CREATE TABLE message_sources (
message_id BIGINT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
raw BYTEA NOT NULL
);
@@ -0,0 +1,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,26 @@
package com.mailserver.config;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import static org.assertj.core.api.Assertions.assertThat;
class DataInitializerTest {
@Test
void generatedPasswordsAreAppendedToAFileOnlyTheOwnerReads(@TempDir Path dir) throws Exception {
Path file = dir.resolve("data/initial-passwords.txt");
DataInitializer.appendPassword(file, "admin@mail.test", "secret-1");
DataInitializer.appendPassword(file, "trap@mail.test", "secret-2");
assertThat(Files.readAllLines(file)).hasSize(2);
assertThat(Files.readAllLines(file).get(0)).endsWith(" admin@mail.test secret-1");
assertThat(Files.readAllLines(file).get(1)).endsWith(" trap@mail.test secret-2");
assertThat(PosixFilePermissions.toString(Files.getPosixFilePermissions(file))).isEqualTo("rw-------");
}
}
@@ -0,0 +1,45 @@
package com.mailserver.config;
import com.mailserver.smtp.SmtpTls;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.HttpHandler;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
class HttpsConfigTest {
@Test
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
HttpHandler handler = (request, response) -> {
response.setStatusCode(HttpStatus.OK);
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
};
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
server.start();
try {
assertThat(server.isRunning()).isTrue();
String body = HttpClient.create()
.secure(spec -> spec.sslContext(trustAll))
.get().uri("https://127.0.0.1:" + server.port() + "/")
.responseContent().aggregate().asString().block();
assertThat(body).isEqualTo("hello https");
} finally {
server.stop();
}
assertThat(server.isRunning()).isFalse();
}
}
@@ -0,0 +1,46 @@
package com.mailserver.config;
import org.junit.jupiter.api.Test;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import java.net.InetSocketAddress;
import static org.assertj.core.api.Assertions.assertThat;
class LoginGuardConfigTest {
private final LoginGuardConfig.TrustedProxyHeaderTransformer transformer = new LoginGuardConfig.TrustedProxyHeaderTransformer();
private ServerHttpRequest from(String peer) {
return MockServerHttpRequest.post("/api/login")
.remoteAddress(new InetSocketAddress(peer, 40000))
.header("X-Forwarded-For", "198.51.100.9")
.header("X-Forwarded-Proto", "https")
.build();
}
@Test
void forwardedForIsBelievedFromAPrivateProxy() {
ServerHttpRequest request = transformer.apply(from("172.18.0.5"));
assertThat(request.getRemoteAddress().getHostString()).isEqualTo("198.51.100.9");
assertThat(request.getURI().getScheme()).isEqualTo("https");
}
@Test
void forwardedForFromTheInternetIsDropped() {
ServerHttpRequest request = transformer.apply(from("203.0.113.7"));
assertThat(request.getRemoteAddress().getAddress().getHostAddress()).isEqualTo("203.0.113.7");
assertThat(request.getHeaders().containsKey("X-Forwarded-For")).isFalse();
}
@Test
void privateRanges() {
for (String ip : new String[]{"127.0.0.1", "10.30.0.13", "172.16.0.1", "192.168.1.1", "fd00::1", "::1", "fe80::1"}) {
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isTrue();
}
for (String ip : new String[]{"103.109.235.154", "172.32.0.1", "2001:db8::1", "8.8.8.8"}) {
assertThat(LoginGuardConfig.privatePeer(new InetSocketAddress(ip, 1))).as(ip).isFalse();
}
}
}
@@ -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();
}
}
@@ -0,0 +1,81 @@
package com.mailserver.controller;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginRequest;
import com.mailserver.security.LoginGuard;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import reactor.core.publisher.Mono;
import java.net.InetSocketAddress;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AuthenticationControllerTest {
private final UserService users = mock(UserService.class);
private final JwtUtils jwt = mock(JwtUtils.class);
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));
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
private final User alice = new User();
AuthenticationControllerTest() {
alice.setEmail("alice@x.test");
alice.setPassword("hash");
when(users.findByEmail("alice@x.test")).thenReturn(Mono.just(alice));
when(users.findByEmail("ghost@x.test")).thenReturn(Mono.empty());
when(users.checkPassword(eq(alice), anyString())).thenAnswer(i -> "secret".equals(i.getArgument(1)));
when(jwt.generateToken(any())).thenReturn("token");
}
private ResponseEntity<?> login(String ip, String email, String password) {
LoginRequest request = new LoginRequest();
request.setEmail(email);
request.setPassword(password);
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login")
.remoteAddress(new InetSocketAddress(ip, 40000)).build();
return controller.login(Mono.just(request), http).block();
}
@Test
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", "alice@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
login("1.2.3.4", "alice@x.test", "y");
ResponseEntity<?> blocked = login("1.2.3.4", "alice@x.test", "secret");
assertThat(blocked.getStatusCode()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS);
assertThat(blocked.getHeaders().getFirst("Retry-After")).isEqualTo("900");
assertThat(blocked.getBody()).isEqualTo(Map.of("error", "Too many failed logins, try again in 15 min"));
assertThat(login("5.6.7.8", "alice@x.test", "secret").getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void blockedAttemptDoesNotCheckThePassword() {
for (int i = 0; i < 3; i++) {
login("1.2.3.4", "ghost@x.test", "x");
}
login("1.2.3.4", "alice@x.test", "secret");
verify(users, never()).checkPassword(any(), eq("secret"));
}
}
@@ -0,0 +1,222 @@
package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
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.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */
class MailboxAdminControllerTest {
private final UserService users = mock(UserService.class);
private final MailService mail = mock(MailService.class);
private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailAdmins admins = new MailAdmins("", "", domains);
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 alice = user(2L, "alice@jambocheck.com");
MailboxAdminControllerTest() {
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.byId(1L)).thenReturn(Mono.just(admin));
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.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) 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.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
void administratorIsTheFirstMailboxByDefault() {
assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue();
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse();
}
@Test
void listsMailboxesSortedForAnAdministratorOnly() {
List<MailboxAdminController.MailboxView> list = controller.list(as("admin@jambocheck.com")).collectList().block();
assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com");
assertThat(list.get(0).admin()).isTrue();
assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
var created = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", "", null))).block();
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
var chosen = controller.create(as("admin@jambocheck.com"),
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1", null))).block();
assertThat(chosen.password()).isNull();
}
@Test
void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() {
assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN);
verify(users, never()).register(anyString(), anyString(), any());
when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists")));
assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
}
@Test
void newPasswordAndSwitchOffButNotYourself() {
var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block();
assertThat(reset.password()).isNotBlank();
verify(users).setPassword(alice, reset.password());
assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse();
assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block()))
.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 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) {
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password, null))).block();
}
private static Mono<Authentication> as(String email) {
return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of()));
}
private static HttpStatus status(Runnable call) {
Throwable error = org.assertj.core.api.Assertions.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,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,373 @@
package com.mailserver.imap;
import com.mailserver.smtp.SmtpTls;
import jakarta.mail.BodyPart;
import jakarta.mail.Flags;
import jakarta.mail.Folder;
import jakarta.mail.Message;
import jakarta.mail.Multipart;
import jakarta.mail.Session;
import jakarta.mail.Store;
import jakarta.mail.UIDFolder;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import jakarta.mail.search.SubjectTerm;
import org.eclipse.angus.mail.imap.AppendUID;
import org.eclipse.angus.mail.imap.IMAPFolder;
import com.mailserver.security.LoginGuard;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import static org.assertj.core.api.Assertions.assertThat;
class ImapServerTest {
static final String PLAIN = """
From: Bob <bob@elsewhere.test>
To: alice@example.test
Subject: Hello Alice
Date: Thu, 05 Nov 2026 10:00:00 +0000
Message-ID: <hello@elsewhere.test>
Content-Type: text/plain; charset=utf-8
Hi there, line one.
Line two.
""";
static final String WITH_FILE = """
From: =?UTF-8?B?0JDQvdC90LA=?= <anna@elsewhere.test>
To: alice@example.test
Subject: =?UTF-8?B?0J7RgtGH0ZHRgg==?=
Date: Thu, 05 Nov 2026 11:00:00 +0000
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="b1"
preamble
--b1
Content-Type: text/plain; charset=utf-8
See the report.
--b1
Content-Type: application/pdf; name="report.pdf"
Content-Disposition: attachment; filename="report.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQgdGVzdA==
--b1--
""";
@TempDir
Path tlsDir;
private MemoryImapStore store;
private ImapServer server;
@BeforeEach
void start() throws Exception {
store = new MemoryImapStore();
store.deliver("INBOX", PLAIN);
store.deliver("INBOX", WITH_FILE);
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
Duration.ofMinutes(5), Duration.ofMillis(200)), store);
server.start();
}
@AfterEach
void stop() {
server.stop();
}
private Store connect() throws Exception {
Properties props = new Properties();
props.put("mail.imap.host", "127.0.0.1");
props.put("mail.imap.port", String.valueOf(server.port()));
props.put("mail.imap.starttls.enable", "true");
props.put("mail.imap.starttls.required", "true");
props.put("mail.imap.ssl.trust", "*");
props.put("mail.imap.ssl.checkserveridentity", "false");
Store imap = Session.getInstance(props).getStore("imap");
imap.connect("alice@example.test", "secret");
return imap;
}
@Test
void realClientReadsFoldersMessagesAndAttachments() throws Exception {
try (Store imap = connect()) {
List<String> names = Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName).toList();
assertThat(names).contains("INBOX", "Drafts", "Sent", "Spam", "Trash");
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
assertThat(inbox.getMessageCount()).isEqualTo(2);
assertThat(inbox.getUnreadMessageCount()).isEqualTo(2);
Message first = inbox.getMessage(1);
assertThat(first.getSubject()).isEqualTo("Hello Alice");
assertThat(((InternetAddress) first.getFrom()[0]).getPersonal()).isEqualTo("Bob");
assertThat(first.getContent().toString()).contains("Line two.");
assertThat(first.isSet(Flags.Flag.SEEN)).isTrue(); // BODY[] without PEEK
Message second = inbox.getMessage(2);
assertThat(second.getSubject()).isEqualTo("Отчёт");
assertThat(((InternetAddress) second.getFrom()[0]).getPersonal()).isEqualTo("Анна");
Multipart parts = (Multipart) second.getContent(); // BODYSTRUCTURE, then BODY[1] and BODY[2]
assertThat(parts.getCount()).isEqualTo(2);
assertThat(parts.getBodyPart(0).getContent().toString()).contains("See the report.");
BodyPart file = parts.getBodyPart(1);
assertThat(file.getFileName()).isEqualTo("report.pdf");
assertThat(new String(file.getInputStream().readAllBytes(), StandardCharsets.US_ASCII)).isEqualTo("%PDF-1.4 test");
assertThat(inbox.search(new SubjectTerm("hello"))).hasSize(1);
assertThat(inbox.getUID(first)).isEqualTo(1);
assertThat(inbox.getMessageByUID(2).getSubject()).isEqualTo("Отчёт");
}
}
@Test
void appendCopyMoveAndExpunge() throws Exception {
try (Store imap = connect()) {
IMAPFolder sent = (IMAPFolder) imap.getFolder("Sent");
MimeMessage outgoing = new MimeMessage(Session.getInstance(new Properties()));
outgoing.setFrom(new InternetAddress("alice@example.test"));
outgoing.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@elsewhere.test"));
outgoing.setSubject("Sent from Thunderbird");
outgoing.setText("body");
AppendUID[] appended = sent.appendUIDMessages(new Message[]{outgoing});
assertThat(appended[0].uid).isEqualTo(1);
assertThat(appended[0].uidvalidity).isEqualTo(store.box("Sent").uidValidity());
IMAPFolder inbox = (IMAPFolder) imap.getFolder("INBOX");
inbox.open(Folder.READ_WRITE);
inbox.copyMessages(new Message[]{inbox.getMessage(1)}, imap.getFolder("Trash"));
inbox.moveMessages(new Message[]{inbox.getMessage(2)}, imap.getFolder("Spam"));
inbox.close(false); // JavaMail counts EXPUNGEd messages until the folder is opened again
inbox.open(Folder.READ_WRITE);
assertThat(inbox.getMessageCount()).isEqualTo(1);
inbox.getMessage(1).setFlag(Flags.Flag.DELETED, true);
inbox.expunge();
assertThat(inbox.getMessageCount()).isZero();
}
assertThat(store.entries(store.box("Trash").id())).hasSize(1);
assertThat(store.entries(store.box("Spam").id())).hasSize(1);
assertThat(store.entries(store.box("Sent").id())).hasSize(1);
}
@Test
void createRenameAndDeleteFolders() throws Exception {
try (Store imap = connect()) {
Folder projects = imap.getFolder("Проекты");
assertThat(projects.create(Folder.HOLDS_MESSAGES)).isTrue();
assertThat(Arrays.stream(imap.getDefaultFolder().list("*")).map(Folder::getFullName)).contains("Проекты");
assertThat(projects.renameTo(imap.getFolder("Archive"))).isTrue();
assertThat(imap.getFolder("Archive").delete(false)).isTrue();
assertThat(imap.getFolder("Archive").exists()).isFalse();
}
}
@Test
void wrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
server.stop();
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
server = new ImapServer(new ImapServer.Settings("127.0.0.1", 0, "mx.example.test", false,
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), false, 1_000_000,
Duration.ofMinutes(5), Duration.ofMillis(200)), store, guard);
server.start();
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
assertThat(client.send("a LOGIN alice@example.test w1")).startsWith("a NO [AUTHENTICATIONFAILED]");
assertThat(client.send("b LOGIN alice@example.test w2")).startsWith("b NO [AUTHENTICATIONFAILED]");
assertThat(client.untilTagged("c", "c LOGIN alice@example.test w3")).contains("* BYE Too many failed logins");
assertThat(client.read()).isNull();
}
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
assertThat(client.send("a LOGIN alice@example.test w4")).startsWith("a NO [AUTHENTICATIONFAILED]");
assertThat(client.untilTagged("b", "b LOGIN alice@example.test secret"))
.anyMatch(line -> line.startsWith("* BYE Too many failed logins, try again in 15 min"))
.anyMatch(line -> line.startsWith("b NO [UNAVAILABLE]"));
}
}
@Test
void loginIsRefusedInPlainTextAndTheGreetingSaysSo() throws Exception {
try (Client client = new Client(server.port())) {
String greeting = client.read();
assertThat(greeting).contains("STARTTLS").contains("LOGINDISABLED").doesNotContain("AUTH=PLAIN");
assertThat(client.send("a LOGIN alice@example.test secret")).startsWith("a NO [PRIVACYREQUIRED]");
}
}
@Test
void literalsAndLiteralPlus() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
client.write("a LOGIN {18}\r\n");
assertThat(client.read()).startsWith("+ ");
client.write("alice@example.test {6+}\r\nsecret\r\n");
assertThat(client.read()).startsWith("a OK");
String message = "Subject: via literal+\r\n\r\nhi\r\n";
client.write("b APPEND Drafts (\\Seen) {" + message.length() + "+}\r\n" + message + "\r\n");
assertThat(client.read()).startsWith("b OK [APPENDUID");
}
assertThat(store.entries(store.box("Drafts").id())).hasSize(1);
}
@Test
void idleReportsNewMailUntilDone() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.tls("s");
client.send("a LOGIN alice@example.test secret");
client.untilTagged("b", "b SELECT INBOX");
client.write("c IDLE\r\n");
assertThat(client.read()).startsWith("+ idling");
store.deliver("INBOX", PLAIN);
assertThat(client.read()).isEqualTo("* 3 EXISTS");
client.write("DONE\r\n");
assertThat(client.read()).startsWith("c OK");
assertThat(client.send("d NOOP")).startsWith("d OK");
}
}
// CVE-2011-0411 class: commands sent in plain text right after STARTTLS must not run inside TLS
@Test
void plainTextAfterStartTlsIsNotExecuted() throws Exception {
try (Client client = new Client(server.port())) {
client.read();
client.write("s STARTTLS\r\nx LOGIN alice@example.test secret\r\n");
assertThat(client.read()).startsWith("s OK");
String reply;
try {
client.startTls();
reply = client.read();
} catch (java.io.IOException closed) {
reply = null; // the server closed the connection — the expected outcome
}
assertThat(reply).isNull();
}
}
@Test
void sectionsOfANestedMessage() {
String raw = ("Subject: outer\r\nContent-Type: multipart/mixed; boundary=x\r\n\r\n"
+ "--x\r\nContent-Type: text/plain\r\n\r\nouter text\r\n"
+ "--x\r\nContent-Type: message/rfc822\r\n\r\nSubject: inner\r\nContent-Type: text/plain\r\n\r\ninner text\r\n"
+ "--x--\r\n");
MimeTree tree = MimeTree.parse(raw.getBytes(StandardCharsets.US_ASCII));
assertThat(text(ImapSession.section(tree, "1"))).isEqualTo("outer text");
assertThat(text(ImapSession.section(tree, "2.HEADER"))).isEqualTo("Subject: inner\r\nContent-Type: text/plain\r\n\r\n");
assertThat(text(ImapSession.section(tree, "2.TEXT"))).isEqualTo("inner text");
assertThat(text(ImapSession.section(tree, "2.1"))).isEqualTo("inner text");
assertThat(text(ImapSession.section(tree, "1.MIME"))).isEqualTo("Content-Type: text/plain\r\n\r\n");
assertThat(text(ImapSession.section(tree, "HEADER.FIELDS (SUBJECT)"))).isEqualTo("Subject: outer\r\n\r\n");
assertThat(text(ImapSession.section(tree, ""))).isEqualTo(raw);
}
@Test
void mailboxNamesUseModifiedUtf7() {
// the example of RFC 3501 5.1.3
assertThat(ImapWriter.encodeMailbox("~peter/mail/台北/日本語")).isEqualTo("~peter/mail/&U,BTFw-/&ZeVnLIqe-");
assertThat(ImapWriter.decodeMailbox("~peter/mail/&U,BTFw-/&ZeVnLIqe-")).isEqualTo("~peter/mail/台北/日本語");
assertThat(ImapWriter.decodeMailbox(ImapWriter.encodeMailbox("Проекты & план"))).isEqualTo("Проекты & план");
}
private static String text(byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
/** Line-oriented IMAP test client. */
static final class Client implements AutoCloseable {
private Socket socket;
private BufferedReader in;
private OutputStream out;
Client(int port) throws Exception {
socket = new Socket("127.0.0.1", port);
socket.setSoTimeout(5000);
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
out = socket.getOutputStream();
}
void write(String text) throws Exception {
out.write(text.getBytes(StandardCharsets.UTF_8));
out.flush();
}
String read() throws Exception {
return in.readLine();
}
String send(String line) throws Exception {
write(line + "\r\n");
return read();
}
/** Sends a command, returns all lines up to its tagged response. */
List<String> untilTagged(String tag, String line) throws Exception {
write(line + "\r\n");
List<String> lines = new ArrayList<>();
String next;
do {
next = read();
lines.add(next);
} while (next != null && !next.startsWith(tag + " "));
return lines;
}
void tls(String tag) throws Exception {
assertThat(send(tag + " STARTTLS")).startsWith(tag + " OK");
startTls();
}
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
@Override
public void close() throws Exception {
socket.close();
}
}
}
@@ -0,0 +1,172 @@
package com.mailserver.imap;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
/** In-memory {@link ImapStore}: one user alice@example.test / secret with the system folders. */
class MemoryImapStore implements ImapStore {
static final class Stored {
final long id;
volatile long folderId;
volatile long uid;
volatile boolean seen;
volatile boolean flagged;
volatile boolean answered;
volatile boolean deleted;
final Instant date;
final byte[] raw;
Stored(long id, long folderId, long uid, byte[] raw, Instant date) {
this.id = id;
this.folderId = folderId;
this.uid = uid;
this.raw = raw;
this.date = date;
}
Entry entry() {
return new Entry(id, uid, seen, flagged, answered, deleted, date);
}
}
private final AtomicLong ids = new AtomicLong(100);
final List<Mailbox> boxes = new CopyOnWriteArrayList<>();
final Map<Long, Long> uidNext = new ConcurrentHashMap<>();
final List<Stored> messages = new CopyOnWriteArrayList<>();
MemoryImapStore() {
for (String name : List.of("INBOX", "Drafts", "Sent", "Spam", "Trash")) {
addBox(name, true);
}
}
private Mailbox addBox(String name, boolean system) {
long id = ids.incrementAndGet();
uidNext.put(id, 1L);
Mailbox box = new Mailbox(id, name, system, 1700000000L + id, 1);
boxes.add(box);
return box;
}
Mailbox box(String name) {
return boxes.stream().filter(b -> b.name().equals(name)).findFirst().orElseThrow();
}
/** Delivers a message into a folder, as SMTP would. */
synchronized Stored deliver(String folder, String raw) {
Mailbox box = box(folder);
Stored stored = new Stored(ids.incrementAndGet(), box.id(), nextUid(box.id()),
raw.replace("\n", "\r\n").replace("\r\r\n", "\r\n").getBytes(), Instant.parse("2026-11-05T10:00:00Z"));
messages.add(stored);
return stored;
}
private synchronized long nextUid(long folderId) {
long uid = uidNext.get(folderId);
uidNext.put(folderId, uid + 1);
return uid;
}
@Override
public Account authenticate(String username, String password) {
return "alice@example.test".equalsIgnoreCase(username) && "secret".equals(password)
? new Account(1, "alice@example.test") : null;
}
@Override
public List<Mailbox> mailboxes(long userId) {
return boxes.stream().map(b -> new Mailbox(b.id(), b.name(), b.system(), b.uidValidity(), uidNext.get(b.id()))).toList();
}
@Override
public synchronized Mailbox create(long userId, String name) {
return addBox(name, false);
}
@Override
public synchronized void rename(long userId, Mailbox mailbox, String newName) {
boxes.removeIf(b -> b.id() == mailbox.id());
boxes.add(new Mailbox(mailbox.id(), newName, false, mailbox.uidValidity(), mailbox.uidNext()));
}
@Override
public synchronized void delete(long userId, Mailbox mailbox) {
Mailbox trash = box("Trash");
move(userId, entries(mailbox.id()), trash);
boxes.removeIf(b -> b.id() == mailbox.id());
}
@Override
public List<Entry> entries(long folderId) {
return messages.stream().filter(m -> m.folderId == folderId)
.sorted((a, b) -> Long.compare(a.uid, b.uid)).map(Stored::entry).toList();
}
@Override
public byte[] source(long messageId) {
return find(messageId).raw;
}
private Stored find(long id) {
return messages.stream().filter(m -> m.id == id).findFirst().orElseThrow();
}
@Override
public void setFlags(long messageId, boolean seen, boolean flagged, boolean answered, boolean deleted) {
Stored m = find(messageId);
m.seen = seen;
m.flagged = flagged;
m.answered = answered;
m.deleted = deleted;
}
@Override
public synchronized long append(long userId, Mailbox target, byte[] raw, boolean seen, boolean flagged,
boolean answered, boolean deleted, Instant internalDate) {
Stored stored = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), raw,
internalDate == null ? Instant.now() : internalDate);
stored.seen = seen;
stored.flagged = flagged;
stored.answered = answered;
stored.deleted = deleted;
messages.add(stored);
return stored.uid;
}
@Override
public synchronized List<Long> copy(long userId, List<Entry> list, Mailbox target) {
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Stored from = find(entry.id());
Stored copy = new Stored(ids.incrementAndGet(), target.id(), nextUid(target.id()), from.raw, from.date);
copy.seen = from.seen;
copy.flagged = from.flagged;
messages.add(copy);
uids.add(copy.uid);
}
return uids;
}
@Override
public synchronized List<Long> move(long userId, List<Entry> list, Mailbox target) {
List<Long> uids = new ArrayList<>();
for (Entry entry : list) {
Stored m = find(entry.id());
m.folderId = target.id();
m.uid = nextUid(target.id());
uids.add(m.uid);
}
return uids;
}
@Override
public void expunge(List<Long> messageIds) {
messages.removeIf(m -> messageIds.contains(m.id));
}
}
@@ -0,0 +1,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,164 @@
package com.mailserver.security;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LoginGuardTest {
/** Clock the test moves by hand. */
static final class TestClock extends Clock {
Instant now = Instant.parse("2026-11-06T12:00:00Z");
void plus(Duration d) {
now = now.plus(d);
}
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
private final TestClock clock = new TestClock();
private LoginGuard guard(int perIp, int perAccount, String... trusted) {
return new LoginGuard(new LoginGuard.Settings(true, perIp, perAccount, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(trusted), Duration.ZERO), clock);
}
@Test
void ipIsBlockedAfterTheLimitWhateverMailboxItTries() {
LoginGuard guard = guard(3, 100);
for (int i = 0; i < 2; i++) {
guard.failure("1.2.3.4", "user" + i + "@x.test");
}
assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse();
guard.failure("1.2.3.4", "user9@x.test");
assertThat(guard.retryAfter("1.2.3.4", "any@x.test")).isEqualTo(Duration.ofMinutes(15));
assertThat(guard.blocked("5.6.7.8", "any@x.test")).isFalse();
clock.plus(Duration.ofMinutes(15));
assertThat(guard.blocked("1.2.3.4", "any@x.test")).isFalse();
}
@Test
void failuresOutsideTheWindowDoNotCount() {
LoginGuard guard = guard(3, 100);
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
clock.plus(Duration.ofMinutes(16));
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
}
@Test
void eachNextBlockOfTheSameIpIsTwiceAsLongUpToTheMaximum() {
LoginGuard guard = guard(2, 100);
Duration expected = Duration.ofMinutes(15);
for (int round = 0; round < 10; round++) {
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
Duration wait = guard.retryAfter("1.2.3.4", "b@x.test");
assertThat(wait).isEqualTo(expected);
clock.plus(wait);
expected = expected.multipliedBy(2).compareTo(Duration.ofHours(24)) > 0 ? Duration.ofHours(24) : expected.multipliedBy(2);
}
// a quiet day forgives
clock.plus(Duration.ofHours(25));
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.retryAfter("1.2.3.4", "b@x.test")).isEqualTo(Duration.ofMinutes(15));
}
@Test
void attemptsWhileBlockedDoNotProlongTheBlock() {
LoginGuard guard = guard(2, 100);
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
clock.plus(Duration.ofMinutes(10));
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.retryAfter("1.2.3.4", "a@x.test")).isEqualTo(Duration.ofMinutes(5));
}
@Test
void distributedAttackBlocksTheMailboxButNotItsOwnerFromAKnownIp() {
LoginGuard guard = guard(100, 5);
guard.success("10.0.0.7", "Alice@X.test");
for (int i = 0; i < 5; i++) {
guard.failure("203.0.113." + i, "alice@x.test");
}
assertThat(guard.blocked("198.51.100.1", "alice@x.test")).isTrue();
assertThat(guard.blocked("198.51.100.1", "bob@x.test")).isFalse();
assertThat(guard.blocked("10.0.0.7", "alice@x.test")).isFalse();
}
@Test
void successForgivesTheIpFailuresButNotAnActiveBlock() {
LoginGuard guard = guard(3, 100);
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
guard.success("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isTrue();
guard.success("1.2.3.4", "a@x.test");
assertThat(guard.blocked("1.2.3.4", "other@x.test")).isTrue();
}
@Test
void trustedIpIsNeverBlockedByIp() {
LoginGuard guard = guard(2, 100, "172.18.0.1");
for (int i = 0; i < 10; i++) {
guard.failure("172.18.0.1", "u" + i + "@x.test");
}
assertThat(guard.blocked("172.18.0.1", "new@x.test")).isFalse();
}
@Test
void sweepForgetsOldKeys() {
LoginGuard guard = guard(5, 5);
guard.failure("1.2.3.4", "a@x.test");
assertThat(guard.trackedIps()).isEqualTo(1);
assertThat(guard.trackedAccounts()).isEqualTo(1);
clock.plus(Duration.ofHours(25).plusMinutes(16));
guard.sweep();
assertThat(guard.trackedIps()).isZero();
assertThat(guard.trackedAccounts()).isZero();
}
@Test
void disabledGuardNeverBlocks() {
LoginGuard guard = LoginGuard.disabled();
for (int i = 0; i < 100; i++) {
guard.failure("1.2.3.4", "a@x.test");
}
assertThat(guard.blocked("1.2.3.4", "a@x.test")).isFalse();
assertThat(guard.failureDelay()).isZero();
}
@Test
void humanDuration() {
assertThat(LoginGuard.human(Duration.ofSeconds(30))).isEqualTo("1 min");
assertThat(LoginGuard.human(Duration.ofMinutes(15))).isEqualTo("15 min");
assertThat(LoginGuard.human(Duration.ofHours(4))).isEqualTo("4 h");
}
}
@@ -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();
}
}
@@ -0,0 +1,153 @@
package com.mailserver.smtp;
import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Mail trap (75.4): existing mailboxes keep their mail, everything else goes into the catch-all mailbox. */
class MailRouterTest {
private final UserRepository users = mock(UserRepository.class);
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private 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) {
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
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,
queue, mock(DkimService.class), client, lists, mock(com.mailserver.service.MailboxAccess.class));
}
@Test
void withoutCatchAllUnknownAndOutsideAddressesAreRefused() {
MailRouter router = router("");
assertThat(router.route("bob@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.RELAY_DENIED);
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND_DISABLED);
}
@Test
void catchAllTakesEverythingWithoutItsOwnMailboxButExistingMailboxesKeepTheirMail() throws Exception {
MailRouter router = router("trap@mail.test");
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.mailbox("bob@mail.test")).isEqualTo("bob@mail.test");
assertThat(router.mailbox("user@gmail.com")).isEqualTo("trap@mail.test");
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "no-reply@jambotron.com", null, List.of(
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("trap@mail.test"), isNull(), 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());
}
@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"));
}
}
@@ -0,0 +1,149 @@
package com.mailserver.smtp;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.StringWriter;
import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.*;
class ReloadingKeyManagerTest {
/** A clock moved by hand */
static final class TestClock extends Clock {
Instant now = Instant.parse("2026-11-06T12:00:00Z");
@Override
public ZoneOffset getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
@Override
public Instant instant() {
return now;
}
}
@Test
void readsTheFilesAgainWhenTheyChange(@TempDir Path dir) throws Exception {
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
SmtpTls.selfSigned("old.example", cert, key);
TestClock clock = new TestClock();
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
String first = manager.chooseEngineServerAlias("RSA", null, null);
assertEquals("CN=old.example", subject(manager.getCertificateChain(first)));
assertNull(manager.chooseEngineServerAlias("EC", null, null), "an RSA key is not offered for EC");
SmtpTls.selfSigned("new.example", cert, key);
Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
Files.setLastModifiedTime(key, FileTime.from(Instant.now().plusSeconds(5)));
assertEquals(first, manager.chooseEngineServerAlias("RSA", null, null), "not checked before the interval");
clock.now = clock.now.plus(Duration.ofMinutes(1));
String second = manager.chooseEngineServerAlias("RSA", null, null);
assertNotEquals(first, second);
assertEquals("CN=new.example", subject(manager.getCertificateChain(second)));
assertNotNull(manager.getPrivateKey(second));
assertNotNull(manager.getCertificateChain(first), "a handshake that holds the previous alias still finishes");
}
@Test
void keepsTheOldCertificateWhenTheNewFileIsBroken(@TempDir Path dir) throws Exception {
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
SmtpTls.selfSigned("old.example", cert, key);
TestClock clock = new TestClock();
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
Files.writeString(cert, "-----BEGIN CERTIFICATE-----\nhalf");
Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
clock.now = clock.now.plus(Duration.ofMinutes(2));
String alias = manager.chooseEngineServerAlias("RSA", null, null);
assertEquals("CN=old.example", subject(manager.getCertificateChain(alias)));
}
@Test
void readsTraditionalEcKeysAsLegoWritesThem(@TempDir Path dir) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(256);
KeyPair pair = generator.generateKeyPair();
X500Name name = new X500Name("CN=mail.example");
X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(
new JcaX509v3CertificateBuilder(name, BigInteger.ONE, new Date(System.currentTimeMillis() - 86_400_000L),
new Date(System.currentTimeMillis() + 86_400_000L), name, pair.getPublic())
.build(new JcaContentSignerBuilder("SHA256withECDSA").build(pair.getPrivate())));
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
Files.writeString(cert, pem(certificate));
Files.writeString(key, sec1(pair)); // -----BEGIN EC PRIVATE KEY----- (SEC1 with the curve), not PKCS#8
assertTrue(Files.readString(key).contains("EC PRIVATE KEY"));
ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock());
String alias = manager.chooseEngineServerAlias("EC", null, null);
assertNotNull(alias);
assertEquals("EC", manager.getPrivateKey(alias).getAlgorithm());
assertNull(manager.chooseEngineServerAlias("RSA", null, null));
}
@Test
void refusesAKeyThatDoesNotMatchTheCertificateType(@TempDir Path dir) throws Exception {
Path cert = dir.resolve("cert.pem");
Path key = dir.resolve("key.pem");
SmtpTls.selfSigned("rsa.example", cert, key);
KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
generator.initialize(256);
Files.writeString(key, sec1(generator.generateKeyPair()));
assertThrows(IllegalStateException.class,
() -> new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock()));
}
/** As lego and openssl ecparam -genkey write it: SEC1 with the named curve secp256r1 */
private static String sec1(KeyPair pair) throws Exception {
java.security.interfaces.ECPrivateKey key = (java.security.interfaces.ECPrivateKey) pair.getPrivate();
org.bouncycastle.asn1.sec.ECPrivateKey sec1 = new org.bouncycastle.asn1.sec.ECPrivateKey(256, key.getS(),
new org.bouncycastle.asn1.x9.X962Parameters(org.bouncycastle.asn1.sec.SECObjectIdentifiers.secp256r1));
StringWriter out = new StringWriter();
try (org.bouncycastle.util.io.pem.PemWriter writer = new org.bouncycastle.util.io.pem.PemWriter(out)) {
writer.writeObject(new org.bouncycastle.util.io.pem.PemObject("EC PRIVATE KEY", sec1.getEncoded()));
}
return out.toString();
}
private static String subject(X509Certificate[] chain) {
return chain[0].getSubjectX500Principal().getName();
}
private static String pem(Object object) throws Exception {
StringWriter out = new StringWriter();
try (JcaPEMWriter writer = new JcaPEMWriter(out)) {
writer.writeObject(object);
}
return out.toString();
}
}
@@ -5,14 +5,22 @@ import jakarta.mail.Session;
import jakarta.mail.Transport; import jakarta.mail.Transport;
import jakarta.mail.internet.InternetAddress; import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import com.mailserver.security.LoginGuard;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.Socket; import java.net.Socket;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
@@ -35,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
@@ -151,6 +159,34 @@ class SmtpServerTest {
assertThat(backend.messages.get(0)).contains("with ESMTPA;"); assertThat(backend.messages.get(0)).contains("with ESMTPA;");
} }
@Test
void threeWrongPasswordsCloseTheConnectionAndTheGuardBlocksTheIp() throws Exception {
server.stop();
LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 4, 100, Duration.ofMinutes(15),
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), java.util.Set.of(), Duration.ZERO));
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true,
Duration.ofSeconds(30)), backend, guard);
server.start();
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w1"))).startsWith("535");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w2"))).startsWith("535");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0w3"))).startsWith("535").contains("closing");
assertThat(client.read()).isNull();
}
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
assertThat(client.send("AUTH PLAIN " + b64("\0bob@example.test\0w4"))).startsWith("535");
// 4th failure from 127.0.0.1: blocked — even the right password is not checked
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("421 4.7.0");
assertThat(client.read()).isNull();
}
}
@Test @Test
void authLoginWorksToo() throws Exception { void authLoginWorksToo() throws Exception {
try (Client client = new Client(server.port())) { try (Client client = new Client(server.port())) {
@@ -214,15 +250,131 @@ class SmtpServerTest {
assertThat(SmtpServer.path("<a b@c>")).isNull(); assertThat(SmtpServer.path("<a b@c>")).isNull();
} }
// ---------- TLS (75.1) ----------
@TempDir
Path tlsDir;
private SmtpServer tlsServer(boolean implicit) throws Exception {
server.stop();
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
Duration.ofSeconds(30), SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), implicit), backend);
server.start();
return server;
}
@Test
void startTlsThenAuthAndTheSessionStartsOver() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
List<String> plain = client.readReplyLines();
assertThat(plain).contains("250-STARTTLS").noneMatch(line -> line.contains("AUTH"));
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("250");
assertThat(client.send("STARTTLS")).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
// RFC 3207: MAIL FROM before STARTTLS is forgotten, EHLO again
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("503");
client.send("EHLO laptop");
List<String> secure = client.readReplyLines();
assertThat(secure).contains("250-AUTH PLAIN LOGIN").noneMatch(line -> line.contains("STARTTLS"));
assertThat(client.send("STARTTLS")).startsWith("503");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("235");
client.send("MAIL FROM:<alice@example.test>");
client.send("RCPT TO:<friend@gmail.com>");
client.send("DATA");
assertThat(client.send("Subject: tls\r\n\r\nhi\r\n.")).startsWith("250");
}
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
// CVE-2011-0411: commands sent in plain text right after STARTTLS must not run inside the TLS session
@Test
void plainTextInjectedAfterStartTlsIsNotExecuted() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
client.write("STARTTLS\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\n");
assertThat(client.read()).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
String reply = client.read();
assertThat(reply == null || reply.startsWith("421")).as(reply).isTrue();
}
assertThat(backend.envelopes).isEmpty();
}
@Test
void realClientRequiringStartTlsDelivers() throws Exception {
tlsServer(false);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over STARTTLS");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.envelopes).hasSize(1);
assertThat(backend.envelopes.get(0).authenticatedUser()).isEqualTo("alice@example.test");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void implicitTlsPortWorksWithoutStartTls() throws Exception {
tlsServer(true);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over 465");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void selfSignedCertificateIsCreatedOnceAndReused() throws Exception {
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
String first = java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE));
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE))).isEqualTo(first);
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.KEY_FILE))).startsWith("-----BEGIN PRIVATE KEY-----");
}
private static String b64(String text) { private static String b64(String text) {
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
} }
/** Line-oriented test client. */ /** Line-oriented test client. */
static final class Client implements AutoCloseable { static final class Client implements AutoCloseable {
private final Socket socket; private Socket socket;
private final BufferedReader in; private BufferedReader in;
private final OutputStream out; private OutputStream out;
Client(int port) throws Exception { Client(int port) throws Exception {
socket = new Socket("127.0.0.1", port); socket = new Socket("127.0.0.1", port);
@@ -231,6 +383,28 @@ class SmtpServerTest {
out = socket.getOutputStream(); out = socket.getOutputStream();
} }
/** Upgrade the connection to TLS (any certificate is trusted — the test server's is self-signed). */
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
void write(String text) throws Exception { void write(String text) throws Exception {
out.write(text.getBytes(StandardCharsets.UTF_8)); out.write(text.getBytes(StandardCharsets.UTF_8));
out.flush(); out.flush();
+84
View File
@@ -0,0 +1,84 @@
# Same stand as docker-compose.jambotron.yml, but from the ready image — nothing is built on the VM:
# gitea.jambotron.com/jambotron_gang/reactive-mail-server (Drone builds it from master, see .drone.yml).
# Pin a build: MAIL_IMAGE_TAG=<first 8 chars of the master commit>; default latest (pulled again on every deploy).
# Test deploy on Jambotron (v0.2.3 75.3): pick this file in the deploy wizard.
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
name: reactive-mail
services:
secrets-init:
image: alpine:3.20
restart: "no"
command:
- sh
- -c
- |
umask 077
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
done
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes:
- secrets:/secrets
db:
# Debian, not -alpine: on Jambotron staging VMs (Alpine 3.24, kernel 6.18) the Alpine build of postgres
# dies with "Bus error" even on --version; the Debian build works (2026-11-05)
image: postgres:17
restart: unless-stopped
depends_on:
secrets-init:
condition: service_completed_successfully
environment:
POSTGRES_DB: mailserver
POSTGRES_USER: mail
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
volumes:
- pgdata:/var/lib/postgresql/data
- secrets:/run/mail-secrets:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
interval: 10s
timeout: 5s
retries: 10
mem_limit: 256m
mail:
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
expose:
- "8080" # web client + API → domain in Jambotron
- "3025" # SMTP, inside the network only
- "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port
environment:
DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
# mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test}
MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside)
OUTBOUND_MODE: "off"
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
SPF_ENABLED: "false"
DMARC_ENABLED: "false"
volumes:
- data:/app/data
- secrets:/run/mail-secrets:ro
mem_limit: 512m
volumes:
secrets:
pgdata:
data:
+8 -4
View File
@@ -2,7 +2,9 @@
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain. # Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
# SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off). # SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off).
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git. # Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
# The first mailbox (admin@<MAIL_DOMAINS>) and its password: in the "mail" logs on the first start. # Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in the "mail" log on the first start
# (Jambotron: project → Services → mail → Logs) and in /app/data/initial-passwords.txt
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
name: reactive-mail name: reactive-mail
services: services:
@@ -14,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
@@ -55,13 +57,15 @@ services:
expose: expose:
- "8080" # web client + API → domain in Jambotron - "8080" # web client + API → domain in Jambotron
- "3025" # SMTP, inside the network only - "3025" # SMTP, inside the network only
- "3143" # IMAP (STARTTLS), inside the network only — open it in Jambotron as an internal TCP port
environment: environment:
DB_HOST: db DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test} MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test} DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test}
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-} # mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox
MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test}
MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside) MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside)
OUTBOUND_MODE: "off" OUTBOUND_MODE: "off"
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
+159
View File
@@ -0,0 +1,159 @@
# Real mail on the internet (v0.2.3 75.6): receives mail for MAIL_DOMAINS and sends it straight to the recipients'
# MX servers (port 25), DKIM-signed. From the ready image, like docker-compose.image.yml.
#
# Needs a VM of its own with a public address (Jambotron 60.8: «Публичный адрес» of the VM, SMTP «да» — port 25
# is closed by default) — the ports below are published on that address:
# 25 SMTP: mail from other servers (STARTTLS) and from mail programs
# 587 submission: mail programs (STARTTLS + login) — the same SMTP listener as 25
# 465 SMTPS: mail programs, implicit TLS
# 993 IMAPS: mail programs, implicit TLS
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
# 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com
# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
#
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
# A mail.<domain> → public address of the VM
# PTR public address → mail.<domain> (hoster's panel: Reverse DNS)
# MX <domain> → 10 mail.<domain>
# TXT <domain> → "v=spf1 ip4:<public address> -all"
# TXT _dmarc.<domain> → "v=DMARC1; p=none; rua=mailto:postmaster@<domain>"
# TXT mail._domainkey.<domain> → printed in the "mail" log on the first start (DKIM key, kept in the data volume)
#
# Mailbox admin@<domain>: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
# Registration is closed: new mailboxes only by the admin. No catch-all.
# Defaults are ours (mail.jambocheck.com / jambocheck.com) — Jambotron deploys without a .env; another domain:
# MAIL_HOSTNAME, MAIL_DOMAINS, ACME_EMAIL in .env next to this file.
name: reactive-mail-public
services:
secrets-init:
image: alpine:3.20
restart: "no"
command:
- sh
- -c
- |
umask 077
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
done
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes:
- secrets:/secrets
# Let's Encrypt for MAIL_HOSTNAME: issued on the first start, renewed 30 days before the end (checked twice a day).
# The deploy hook copies the pair to /tls (readable by the "mail" user 10001); the server reads it again within
# a minute — no restart
acme:
image: goacme/lego:v5.5.2
restart: unless-stopped
ports:
- "80:80"
entrypoint:
- sh
- -c
- |
cat > /usr/local/bin/jb-deploy <<'EOF'
#!/bin/sh
set -e
cp "$$LEGO_HOOK_CERT_PATH" /tls/cert.pem.new
cp "$$LEGO_HOOK_CERT_KEY_PATH" /tls/key.pem.new
chown 10001 /tls/cert.pem.new /tls/key.pem.new
chmod 600 /tls/key.pem.new
chmod 644 /tls/cert.pem.new
mv /tls/key.pem.new /tls/key.pem
mv /tls/cert.pem.new /tls/cert.pem
echo "certificate for $$LEGO_HOOK_CERT_DOMAINS copied to /tls"
EOF
chmod 755 /usr/local/bin/jb-deploy
while true; do
/lego run --accept-tos --email "$$ACME_EMAIL" --domains "$$MAIL_HOSTNAME" --key-type EC256 \
--http --http.address :80 --path /var/lib/lego --renew-days 30 --no-random-sleep \
--deploy-hook /usr/local/bin/jb-deploy || echo "lego failed — next try in 1 hour"
[ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
done
environment:
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
ACME_EMAIL: ${ACME_EMAIL:-postmaster@jambocheck.com}
LEGO_LOG_FORMAT: text
volumes:
- lego:/var/lib/lego
- tls:/tls
healthcheck:
test: ["CMD-SHELL", "test -s /tls/cert.pem && test -s /tls/key.pem"]
interval: 15s
timeout: 5s
start_period: 10m
retries: 3
mem_limit: 128m
db:
image: postgres:17
restart: unless-stopped
depends_on:
secrets-init:
condition: service_completed_successfully
environment:
POSTGRES_DB: mailserver
POSTGRES_USER: mail
POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
volumes:
- pgdata:/var/lib/postgresql/data
- secrets:/run/mail-secrets:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
interval: 10s
timeout: 5s
retries: 10
mem_limit: 256m
mail:
image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
pull_policy: always
restart: unless-stopped
depends_on:
db:
condition: service_healthy
acme:
condition: service_healthy # waits for the first certificate
ports:
- "25:3025"
- "587:3025"
- "465:3465"
- "993:3993"
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
expose:
- "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address)
environment:
DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
MAIL_TLS_CERT: /tls/cert.pem
MAIL_TLS_KEY: /tls/key.pem
SMTPS_PORT: "3465"
IMAPS_PORT: "3993"
HTTPS_PORT: "8443"
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
DKIM_SELECTOR: mail
DKIM_KEYS_DIR: /app/data/dkim-keys
MAIL_CATCH_ALL: ""
MAIL_REGISTRATION: "false"
OUTBOUND_MODE: mx
SMTP_AUTH_WITHOUT_TLS: "false"
IMAP_AUTH_WITHOUT_TLS: "false"
SPF_ENABLED: "true"
DMARC_ENABLED: "true"
volumes:
- data:/app/data
- secrets:/run/mail-secrets:ro
- tls:/tls:ro
mem_limit: 512m
volumes:
secrets:
pgdata:
data:
lego:
tls:
+45 -3
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, Message, Profile, ServerConfig } from './models'; import { 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' })
@@ -17,6 +17,48 @@ export class ApiService {
return this.http.put('/api/me/password', { currentPassword, newPassword }); return this.http.put('/api/me/password', { currentPassword, newPassword });
} }
// mailboxes — administrators only
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
createMailbox(email: string, displayName: string, password: string, shared = false): Observable<MailboxCreated> {
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> {
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> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
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'); }
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); } createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
@@ -42,8 +84,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
+43
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,44 @@ 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(); }
}
logout(): void { logout(): void {
this.clear(); this.clear();
this.router.navigate(['/login']); this.router.navigate(['/login']);
@@ -33,8 +73,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 {
+42
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 {
@@ -53,6 +63,38 @@ export interface Profile {
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; createdAt: string;
admin?: boolean; // may manage mailboxes (Settings → Mailboxes)
}
/** MailboxAdminController.MailboxView */
export interface Mailbox {
id: number;
email: string;
displayName: string;
active: 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;
quotaBytes: number;
createdAt: string;
}
/** 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 */
export interface MailboxCreated {
mailbox: Mailbox;
password: string | null;
} }
export interface ServerConfig { export interface ServerConfig {
+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('');
@@ -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,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))
});
}
}
@@ -0,0 +1,244 @@
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 { MailStateService } from '../../core/mail-state.service';
import { Mailbox, errorText, formatBytes } from '../../core/models';
import { ToastService } from '../../ui/toast.service';
import { AuthService } from '../../core/auth.service';
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
@Component({
selector: 'app-mailboxes-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="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">
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
</h2>
<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 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>
<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 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>
<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>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
@if (secret(); as s) {
<div class="p-3 mb-4 rounded-lg border border-pink-500/40 bg-slate-900/60" role="status">
<div class="text-xs text-slate-400 mb-1">Password of <span class="font-mono text-cyan-300">{{ s.email }}</span> — shown once, copy it now</div>
<div class="flex items-center gap-2">
<code class="font-mono text-pink-300 break-all select-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="divide-y divide-cyan-500/10">
@for (m of mailboxes(); track m.id) {
<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="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.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>
@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) {
<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()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<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>
}
</ul>
</section>
`
})
export class MailboxesSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private toasts = inject(ToastService);
private auth = inject(AuthService);
readonly mailboxes = signal<Mailbox[]>([]);
readonly secret = signal<{ email: string; password: string } | null>(null);
readonly error = signal('');
readonly busy = signal(false);
readonly me = computed(() => this.state.profile()?.email ?? '');
readonly showPw = signal(false);
readonly local = signal('');
// Домены сервера (/api/config); пока не пришли — домен своего ящика
readonly domains = signal<string[]>([]);
readonly domain = signal('');
name = '';
password = '';
shared = false;
/** mailbox whose members are being edited */
readonly membersOf = signal<number | null>(null);
readonly picked = signal<Set<string>>(new Set());
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();
}
size(bytes: number): string {
return formatBytes(bytes);
}
load(): void {
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: e => this.error.set(errorText(e)) });
}
create(): void {
const local = this.local().replace(/@.*$/, '');
if (!local) { this.error.set('Enter an address'); return; }
const email = local + '@' + this.domain();
this.error.set('');
this.busy.set(true);
this.api.createMailbox(email, this.name.trim(), this.password, this.shared).subscribe({
next: created => {
this.busy.set(false);
this.local.set('');
this.name = this.password = '';
this.shared = false;
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
this.load();
},
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
});
}
newPassword(m: Mailbox): void {
if (!confirm('New password for ' + m.email + '? The old one stops working at once.')) return;
this.api.newMailboxPassword(m.id).subscribe({
next: created => created.password && this.secret.set({ email: m.email, password: created.password }),
error: e => this.toasts.error(errorText(e))
});
}
/** 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. */
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 {
this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(),
error: e => this.toasts.error(errorText(e))
});
}
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 {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
}
}
@@ -7,11 +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 { ListsSection } from './lists.section';
/** Profile name, password, storage, look. */ /** Profile name, password, storage, look. */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule], imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection],
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">
@@ -41,11 +43,23 @@ import { ToastService } from '../../ui/toast.service';
<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>
@@ -97,6 +111,11 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</div> </div>
</section> </section>
@if (state.profile()?.admin) {
<app-mailboxes-section />
<app-lists-section />
}
</div> </div>
` `
}) })
@@ -112,6 +131,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));
+107 -3
View File
@@ -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,56 @@ 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">
<div class="flex items-center justify-between mb-2 px-1">
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
</div>
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
@for (b of boxes(); track b.email) {
<li class="flex items-center gap-1">
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
[class.text-slate-300]="b.email !== auth.email()"
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
@if (b.unread) {
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
}
</button>
@if (b.kind !== 'OWN') {
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
}
</li>
}
</ul>
@if (attachOpen()) {
<form class="space-y-2 mb-4 p-2 rounded-lg border border-cyan-500/20" (ngSubmit)="attach()" aria-label="Add a mailbox">
<label class="synth-label" for="att-email">Mailbox</label>
<input id="att-email" name="attEmail" class="synth-input !py-1.5" [(ngModel)]="attachEmail" autocomplete="off" placeholder="name&#64;domain">
<label class="synth-label" for="att-pw">Its password</label>
<input id="att-pw" name="attPw" type="password" class="synth-input !py-1.5" [(ngModel)]="attachPassword" autocomplete="off">
<button type="submit" class="cyber-btn w-full py-1.5 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
</form>
}
<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>
@@ -156,6 +203,11 @@ export class ShellComponent {
readonly themes = THEMES; readonly themes = THEMES;
readonly menuOpen = signal(false); readonly menuOpen = signal(false);
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
readonly boxes = signal<MailboxEntry[]>([]);
readonly attachOpen = signal(false);
attachEmail = '';
attachPassword = '';
query = ''; query = '';
readonly links = [ readonly links = [
@@ -186,7 +238,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.loadBoxes();
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 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 +300,57 @@ export class ShellComponent {
} }
} }
loadBoxes(): void {
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
}
/** 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))
});
}
attach(): void {
const email = this.attachEmail.trim().toLowerCase();
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
this.api.attachMailbox(email, this.attachPassword).subscribe({
next: b => {
this.attachEmail = this.attachPassword = '';
this.attachOpen.set(false);
this.toasts.ok(b.email + ' added to your mailboxes');
this.loadBoxes();
},
error: e => this.toasts.error(errorText(e))
});
}
leave(b: MailboxEntry): void {
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
? 'It disappears from your menu. An administrator can give it back.'
: 'It disappears from your menu. 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()) {
const own = this.boxes().find(x => x.kind === 'OWN');
if (own) { this.switchTo(own); return; }
}
this.loadBoxes();
},
error: e => this.toasts.error(errorText(e))
});
});
}
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');
}
logout(): void { logout(): void {
this.state.reset(); this.state.reset();
this.auth.logout(); this.auth.logout();