Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)

- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея,
  MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера
- отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке;
  Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM)
- DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается
- права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../
- секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта,
  admin123 и /api/simulate/receive — только профиль dev
- веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health;
  docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080
- gradle-wrapper.jar был в .gitignore — сборка не запускалась
- тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jambotron α
2026-11-05 03:37:43 +01:00
parent b6f6d41813
commit 165edf1a1e
44 changed files with 2626 additions and 1715 deletions
@@ -0,0 +1,14 @@
# Local development only (./gradlew bootRun --args='--spring.profiles.active=dev'): Postgres from docker-compose.yml
DB_PORT=5433
DB_PASSWORD=secret
JWT_SECRET=dev-only-jwt-secret-not-for-any-real-server-0123456789
mailserver.domains=mailserver.local
mailserver.admin.email=admin@mailserver.local
mailserver.admin.password=admin123
mailserver.registration.enabled=true
mailserver.cors.origins=http://localhost:4200
mailserver.smtp.auth-without-tls=true
mailserver.dkim.domain=mailserver.local
mailserver.spf.enabled=false
mailserver.dmarc.enabled=false
logging.level.com.mailserver=DEBUG
@@ -1,54 +0,0 @@
spring.application.name=mail-server
server.port=8080
# R2DBC — реактивный доступ к PostgreSQL
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mailserver
spring.r2dbc.username=mail
spring.r2dbc.password=secret
# Flyway — миграции базы данных
spring.flyway.enabled=true
spring.flyway.url=jdbc:postgresql://localhost:5432/mailserver
spring.flyway.user=mail
spring.flyway.password=secret
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
# Вложения
mailserver.attachments.dir=./attachments
spring.webflux.multipart.max-file-size=10MB
spring.webflux.multipart.max-request-size=10MB
# SMTP-сервер для приёма входящих писем
mailserver.smtp.port=3025
mailserver.smtp.hostname=your-domain.com
# IMAP-сервер (отключён)
mailserver.imap.enabled=false
mailserver.imap.port=1143
mailserver.imap.hostname=localhost
# DKIM — криптографическая подпись писем
mailserver.dkim.enabled=false
mailserver.dkim.domain=your-domain.com
mailserver.dkim.keys-dir=./dkim-keys
# SPF — проверка отправителей через DNS
mailserver.spf.enabled=false
mailserver.spf.local-bypass=true
# DMARC — политика обработки писем, не прошедших SPF/DKIM
mailserver.dmarc.enabled=false
mailserver.dmarc.local-bypass=true
# SMTP релей — доставка писем на внешнюю почту
# Порт 25 — стандартный SMTP relay между серверами (VPS)
mailserver.relay.enabled=true
mailserver.relay.port=25
mailserver.relay.starttls=false
mailserver.relay.auth-enabled=false
mailserver.relay.username=
mailserver.relay.password=
logging.level.com.mailserver=INFO
@@ -1,54 +1,71 @@
spring.application.name=mail-server
server.port=8080
# R2DBC — реактивный доступ к PostgreSQL
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mailserver
spring.r2dbc.username=mail
spring.r2dbc.password=secret
# Secrets: environment variables, or files in a config tree (file name = variable name, e.g. /run/mail-secrets/DB_PASSWORD).
# No defaults — the server does not start without them (dev profile: application-dev.properties).
spring.config.import=optional:configtree:${MAILSERVER_SECRETS_DIR:/run/mail-secrets}/
# Flyway — миграции базы данных
# PostgreSQL: R2DBC for the app, JDBC for Flyway migrations
spring.r2dbc.url=r2dbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver}
spring.r2dbc.username=${DB_USER:mail}
spring.r2dbc.password=${DB_PASSWORD}
spring.flyway.enabled=true
spring.flyway.url=jdbc:postgresql://localhost:5432/mailserver
spring.flyway.user=mail
spring.flyway.password=secret
spring.flyway.url=jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mailserver}
spring.flyway.user=${DB_USER:mail}
spring.flyway.password=${DB_PASSWORD}
spring.flyway.locations=classpath:db/migration
spring.flyway.baseline-on-migrate=true
# Вложения
mailserver.attachments.dir=./attachments
jwt.secret=${JWT_SECRET}
# Mailboxes of these domains live here; catch-all: every message to any address goes into this one mailbox (mail trap)
mailserver.domains=${MAIL_DOMAINS:localhost}
mailserver.catch-all=${MAIL_CATCH_ALL:}
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments
mailserver.attachments.dir=${MAIL_ATTACHMENTS_DIR:./attachments}
spring.webflux.multipart.max-file-size=10MB
spring.webflux.multipart.max-request-size=10MB
# SMTP-сервер для приёма входящих писем
mailserver.smtp.port=3025
mailserver.smtp.hostname=localhost
# SMTP server (incoming mail and submission). AUTH without TLS only in a closed network (docker network, VM)
mailserver.smtp.port=${SMTP_PORT:3025}
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
mailserver.smtp.max-message-bytes=26214400
# IMAP-сервер для доступа к письмам
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
mailserver.outbound.mode=${OUTBOUND_MODE:off}
mailserver.outbound.host=${OUTBOUND_HOST:}
mailserver.outbound.port=${OUTBOUND_PORT:587}
mailserver.outbound.username=${OUTBOUND_USERNAME:}
mailserver.outbound.password=${OUTBOUND_PASSWORD:}
mailserver.outbound.tls=${OUTBOUND_TLS:starttls}
# IMAP — off: simplified, blocks the event loop (v0.2.3 75.2)
mailserver.imap.enabled=false
mailserver.imap.port=1143
mailserver.imap.hostname=localhost
# DKIM — криптографическая подпись писем
mailserver.dkim.enabled=false
mailserver.dkim.domain=localhost
mailserver.dkim.keys-dir=./dkim-keys
# DKIM — signing of our mail; DNS record printed to the log at start
mailserver.dkim.enabled=${DKIM_ENABLED:true}
mailserver.dkim.domain=${DKIM_DOMAIN:localhost}
mailserver.dkim.selector=${DKIM_SELECTOR:mail}
mailserver.dkim.keys-dir=${DKIM_KEYS_DIR:./dkim-keys}
# SPF — проверка отправителей через DNS
mailserver.spf.enabled=false
# SPF / DMARC for incoming mail
mailserver.spf.enabled=${SPF_ENABLED:true}
mailserver.spf.local-bypass=true
# DMARC — политика обработки писем, не прошедших SPF/DKIM
mailserver.dmarc.enabled=false
mailserver.dmarc.enabled=${DMARC_ENABLED:true}
mailserver.dmarc.local-bypass=true
# SMTP релей — доставка писем на внешнюю почту
# Порт 587 — submission с STARTTLS (для отправки через SMTP-сервер)
mailserver.relay.enabled=true
mailserver.relay.port=587
mailserver.relay.starttls=true
mailserver.relay.auth-enabled=false
mailserver.relay.username=
mailserver.relay.password=
# error responses carry the reason ("No such mailbox", "Sending outside is disabled") for the web client
server.error.include-message=always
logging.level.com.mailserver=DEBUG
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=never
logging.level.com.mailserver=INFO
@@ -0,0 +1,15 @@
-- Очередь отправки наружу: письмо хранится как готовые байты (уже с DKIM-подписью) до доставки
CREATE TABLE outbound_queue (
id BIGSERIAL PRIMARY KEY,
mail_from VARCHAR(255) NOT NULL,
rcpt_to VARCHAR(255) NOT NULL,
message BYTEA NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_error VARCHAR(1000),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP
);
CREATE INDEX idx_outbound_queue_due ON outbound_queue(next_attempt_at) WHERE status = 'PENDING';