Files
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

17 KiB
Raw Permalink Blame History

Reactive Mail Server

Почтовый сервер на Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.

Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend.

Как устроено

 SMTP-клиент / чужой сервер ──► SmtpServer (Netty, порт 3025) ──┐
                                                               ├─► MailRouter ─┬─► LocalDelivery → свой ящик (PostgreSQL)
 Веб-почта (Angular) ──► REST /api ──► SubmissionService ──────┘               └─► OutboundQueue → DeliveryWorker → SMTP получателя
  • Приём (SMTP). Команды разбираются по строкам (LineBasedFrameDecoder): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь до ответа 250; если сохранить не удалось — 451, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
  • TLS. STARTTLS на порту SMTP (RFC 3207) и, если задан SMTPS_PORT, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После STARTTLS сессия начинается заново (новый EHLO), а команды, присланные открытым текстом вместе со STARTTLS, не выполняются — соединение закрывается (CVE-2011-0411). AUTH предлагается только внутри TLS (или без него при SMTP_AUTH_WITHOUT_TLS=true). Сертификат — свой (MAIL_TLS_CERT + MAIL_TLS_KEY, например Let's Encrypt) или самоподписанный: создаётся один раз в MAIL_TLS_DIR и потом переиспользуется.
  • Не открытый релей.
    • Письмо на свой домен принимается от кого угодно.
    • Письмо на чужой домен — только после AUTH, иначе 550 5.7.1 Relaying denied.
    • Вошедший пользователь пишет только от своего адреса, иначе 553.
  • Маршрут (MailRouter) один для SMTP и веб-почты:
    • есть такой ящик в нашем домене → в него;
    • задан MAIL_CATCH_ALL → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
    • наш домен без такого ящика → «нет такого пользователя»;
    • чужой домен → очередь.
  • Отправка наружу (OUTBOUND_MODE):
    • off — ничего не уходит;
    • mx — напрямую на MX получателя, порт 25, STARTTLS по возможности;
    • smarthost — через провайдера (Resend, SES) с логином и проверкой TLS.
    • Очередь — таблица outbound_queue. Временная ошибка — повтор через 1 мин, 5 мин, 15 мин, 1 ч, 3 ч, 6 ч, 12 ч. Постоянная ошибка или исчерпанные повторы — уведомление отправителю во входящие.
  • DKIM. Подпись — utils-mail-dkim (rsa-sha256, relaxed/relaxed), проверка входящих — Apache James jDKIM по ключу из DNS отправителя. Ключ создаётся один раз в DKIM_KEYS_DIR, запись для DNS печатается в лог при старте: TXT <selector>._domainkey.<domain>.
  • Права. Письма, папки и вложения отдаются только владельцу (чужие — 404). Регистрация по умолчанию закрыта, ящики — только в своих доменах.

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-шаблона: стеклянные панели, неон, движущаяся сетка (выключается в настройках и при prefers-reduced-motion), четыре палитры и высокий контраст. Все таблицы — грид @jambotron/aria-grid (сортировка, страницы, выбор строк, массовые действия), пакет — из реестра npm Gitea (frontend/.npmrc).

  • Вход и регистрация. Регистрация (если открыта, MAIL_REGISTRATION) — имя ящика в одном из MAIL_DOMAINS, после неё вход сразу. Служебные имена (postmaster, abuse, admin…) заняты. Пароль — от 8 символов.
  • Папки. Входящие, Черновики, Отправленные, Спам, Корзина и свои (создать, переименовать, удалить — письма уходят в Корзину). Счётчики непрочитанных обновляются раз в 30 с.
  • Список писем. Поиск по теме, отправителю, получателям и тексту; флажок; прочитано/не прочитано; перенос в папку, в Спам и обратно; удаление (в Корзину, из Корзины — насовсем); очистка Корзины и Спама.
  • Письмо. HTML-письма — в изолированном iframe без скриптов, внешние картинки — только по кнопке (отправитель не узнаёт, что письмо открыли). Вложения скачиваются. Заблокировать отправителя — его письма сразу идут в Спам.
  • Написать. Несколько адресов в «Кому» и «Копия», подсказки из контактов, ответ, ответ всем, пересылка. Черновик сохраняется сам каждые 15 с; файлы (до 10 МБ) прикрепляются к черновику и уходят вместе с письмом.
  • Контакты, заблокированные отправители, настройки (имя, пароль, место, оформление).

API: GET /api/config (без входа), /api/me, /api/me/password, /api/folders, /api/messages (PATCH — read/flagged/folderId), /api/drafts, /api/send (draftId — с вложениями черновика), /api/contacts, /api/spam/blacklist.

Настройки (переменные окружения)

Секреты без значений по умолчанию — без них сервер не стартует. Их можно передать переменными или файлами в MAILSERVER_SECRETS_DIR (имя файла = имя переменной).

Переменная Что По умолчанию
DB_PASSWORD пароль PostgreSQL обязательно
JWT_SECRET ключ токенов входа, ≥ 32 байт обязательно
DB_HOST, DB_PORT, DB_NAME, DB_USER база localhost, 5432, mailserver, mail
MAIL_DOMAINS наши домены, через запятую localhost
MAIL_HOSTNAME имя сервера в SMTP localhost
MAIL_ADMIN_EMAIL, MAIL_ADMIN_PASSWORD первый ящик на пустой базе; без пароля — случайный (в MAIL_PASSWORDS_FILE или один раз в логе) admin@<первый домен>
MAIL_PASSWORDS_FILE файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки — (в образе /app/data/initial-passwords.txt)
MAIL_REGISTRATION открыть регистрацию false
MAIL_CATCH_ALL ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте —
SMTP_PORT порт SMTP 3025
MAIL_TLS TLS для SMTP: STARTTLS и порт SMTPS_PORT true
MAIL_TLS_CERT, MAIL_TLS_KEY свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный —
MAIL_TLS_DIR где лежит самоподписанный сертификат ./tls (в образе /app/data/tls)
SMTPS_PORT порт неявного TLS (465); 0 — выключен 0
IMAP_ENABLED, IMAP_PORT, IMAPS_PORT IMAP: включён; порт со STARTTLS; порт неявного TLS (0 — выключен) true, 3143, 0
IMAP_AUTH_WITHOUT_TLS вход в IMAP без TLS — только в закрытой сети false
SMTP_AUTH_WITHOUT_TLS разрешить AUTH без шифрования — только в закрытой сети (docker, ВМ) false
OUTBOUND_MODE off / mx / smarthost off
OUTBOUND_HOST, OUTBOUND_PORT, OUTBOUND_USERNAME, OUTBOUND_PASSWORD, OUTBOUND_TLS smarthost (starttls / ssl / none) —, 587, —, —, starttls
DKIM_DOMAIN, DKIM_SELECTOR, DKIM_KEYS_DIR DKIM localhost, mail, ./dkim-keys
SPF_ENABLED, DMARC_ENABLED проверки входящих true
MAIL_CORS_ORIGINS другие источники для веб-клиента (только разработка) —

Разработка

docker compose up -d                                               # PostgreSQL на 127.0.0.1:5433
./gradlew :backend:bootRun --args='--spring.profiles.active=dev'   # API :8080, SMTP :3025, admin@mailserver.local / admin123
cd frontend && npm install && npx ng serve                         # http://localhost:4200, /api → :8080
./gradlew :backend:test                                            # тесты (база не нужна)

Профиль dev — только для своего компьютера: известные пароли, открытая регистрация, POST /api/simulate/receive (письмо в ящик без SMTP).

Деплой на Jambotron (тест)

В мастере деплоя выбрать docker-compose.jambotron.yml, домен дать сервису mail, порт 8080.

  • Пароль базы и ключ токенов создаёт контейнер secrets-init при первом запуске (том secrets) — вводить ничего не нужно, в git ничего нет.
  • Ящики admin@mail.test (первый) и trap@mail.test (ловушка) создаются при старте, их пароли — в файле /app/data/initial-passwords.txt контейнера mail (том data, права 600): docker exec <контейнер mail> cat /app/data/initial-passwords.txt. Прочитать и сменить пароль в настройках.
  • Регистрация открыта (MAIL_REGISTRATION=true): любой может завести ящик в MAIL_DOMAINS. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — MAIL_REGISTRATION=false.
  • Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (OUTBOUND_MODE=off).
  • Домен ящиков — MAIL_DOMAINS (по умолчанию mail.test).

Ловушка почты для 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.

Не сделано

  • Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
  • IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по BODY/TEXT без перебора писем.
  • Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
  • Встроенные картинки HTML-писем (cid:) показываются как вложения, а не внутри письма.
  • Защита регистрации и входа от перебора (лимит попыток, капча).
  • Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
  • Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).

Лицензия

MIT