Безопасность, 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,83 @@
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.UserService;
import org.springframework.stereotype.Service;
import java.util.Locale;
/**
* 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;
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
*/
@Service
public class MailRouter implements SmtpBackend {
private final MailDomains domains;
private final UserRepository users;
private final UserService userService;
private final LocalDelivery localDelivery;
private final OutboundQueue queue;
private final DkimService dkim;
private final SmtpDeliveryClient client;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery,
OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) {
this.domains = domains;
this.users = users;
this.userService = userService;
this.localDelivery = localDelivery;
this.queue = queue;
this.dkim = dkim;
this.client = client;
}
@Override
public boolean authenticate(String username, String password) {
User user = users.findByEmail(username.toLowerCase(Locale.ROOT)).block();
return user != null && user.isActive() && userService.checkPassword(user, password);
}
@Override
public Route route(String recipient, String authenticatedUser) {
if (domains.catchAll() != null) {
return Route.LOCAL;
}
if (domains.isLocal(recipient)) {
return Boolean.TRUE.equals(users.existsByEmail(recipient.toLowerCase(Locale.ROOT)).block())
? Route.LOCAL : Route.UNKNOWN_USER;
}
if (authenticatedUser == null) {
return Route.RELAY_DENIED;
}
return client.mode() == SmtpDeliveryClient.Mode.OFF ? Route.OUTBOUND_DISABLED : Route.OUTBOUND;
}
@Override
public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null;
byte[] signed = null;
for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) {
case LOCAL -> {
String mailbox = domains.catchAll() != null ? domains.catchAll() : recipient.address();
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
}
case OUTBOUND -> {
if (signed == null) {
signed = dkim.sign(message);
}
queue.enqueue(envelope.mailFrom(), recipient.address(), signed).block();
}
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
}
}
}
}