Безопасность, 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,88 @@
package com.mailserver.delivery;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class DeliveryWorkerTest {
private OutboundRepository repository;
private final List<String> bounces = new ArrayList<>();
private SmtpDeliveryClient.Result next;
@BeforeEach
void setUp() {
repository = mock(OutboundRepository.class);
when(repository.save(any())).thenAnswer(call -> Mono.just(call.getArgument(0)));
}
private DeliveryWorker worker() {
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX,
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx", 1000, 25), domain -> List.of("mx")) {
@Override
public Result deliver(String from, String recipient, byte[] message) {
return next;
}
};
return new DeliveryWorker(repository, client, (message, reason) -> bounces.add(message.getRcptTo() + ": " + reason));
}
private static OutboundMessage queued() {
OutboundMessage message = new OutboundMessage();
message.setId(1L);
message.setMailFrom("alice@ours.test");
message.setRcptTo("bob@theirs.test");
message.setMessage(new byte[]{1});
return message;
}
@Test
void deliveredIsDone() {
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.DELIVERED, "mx");
OutboundMessage message = queued();
worker().attempt(message, Instant.EPOCH);
assertThat(message.getStatus()).isEqualTo(OutboundMessage.DELIVERED);
assertThat(message.getLastError()).isNull();
assertThat(bounces).isEmpty();
}
@Test
void temporaryFailureIsRetriedWithGrowingPausesThenBounced() {
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.TEMPORARY, "451 greylisted");
OutboundMessage message = queued();
DeliveryWorker worker = worker();
Instant now = Instant.EPOCH;
List<Duration> pauses = new ArrayList<>();
for (int i = 0; i < DeliveryWorker.RETRY_PAUSES.size(); i++) {
worker.attempt(message, now);
assertThat(message.getStatus()).isEqualTo(OutboundMessage.PENDING);
pauses.add(Duration.between(now, message.getNextAttemptAt()));
now = message.getNextAttemptAt();
}
assertThat(pauses).isEqualTo(DeliveryWorker.RETRY_PAUSES);
worker.attempt(message, now);
assertThat(message.getStatus()).isEqualTo(OutboundMessage.FAILED);
assertThat(bounces).singleElement().asString().contains("gave up after 8 attempts").contains("451 greylisted");
}
@Test
void permanentFailureBouncesAtOnce() {
next = new SmtpDeliveryClient.Result(SmtpDeliveryClient.Outcome.PERMANENT, "550 no such user");
OutboundMessage message = queued();
worker().attempt(message, Instant.EPOCH);
assertThat(message.getStatus()).isEqualTo(OutboundMessage.FAILED);
assertThat(message.getLastError()).isEqualTo("550 no such user");
assertThat(bounces).containsExactly("bob@theirs.test: 550 no such user");
}
}
@@ -0,0 +1,109 @@
package com.mailserver.delivery;
import com.icegreen.greenmail.util.GreenMail;
import com.icegreen.greenmail.util.ServerSetup;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SmtpDeliveryClientTest {
private static final String MESSAGE = "From: alice@ours.test\r\nTo: bob@theirs.test\r\nSubject: Hi\r\n"
+ "DKIM-Signature: v=1; a=rsa-sha256; d=ours.test; s=mail; h=from; bh=x; b=y\r\n\r\nbody\r\n.dot line\r\n";
private GreenMail greenMail;
@BeforeEach
void start() {
greenMail = new GreenMail(new ServerSetup(0, "127.0.0.1", ServerSetup.PROTOCOL_SMTP));
greenMail.setUser("relay-user", "relay-pass");
greenMail.start();
}
@AfterEach
void stop() {
greenMail.stop();
}
private int port() {
return greenMail.getSmtp().getPort();
}
private SmtpDeliveryClient mxClient(int mxPort, List<String> hosts) {
return new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX, "", 0, "", "",
SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, mxPort), domain -> hosts);
}
@Test
void deliversToTheMxAndKeepsTheBytesUnchanged() throws Exception {
SmtpDeliveryClient.Result result = mxClient(port(), List.of("127.0.0.1"))
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
assertThat(result.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
MimeMessage[] received = greenMail.getReceivedMessages();
assertThat(received).hasSize(1);
ByteArrayOutputStream out = new ByteArrayOutputStream();
received[0].writeTo(out);
// GreenMail adds its Received/Return-Path; everything we sent is there byte for byte (DKIM stays valid)
assertThat(out.toString(StandardCharsets.UTF_8)).contains(MESSAGE.stripTrailing());
}
@Test
void triesTheNextMxWhenTheFirstDoesNotAnswer() throws Exception {
int closed;
try (ServerSocket socket = new ServerSocket(0)) {
closed = socket.getLocalPort();
}
// first "host" refuses the connection, second is GreenMail — both on 127.0.0.1, so use two clients' ports:
SmtpDeliveryClient.Result refused = mxClient(closed, List.of("127.0.0.1"))
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
assertThat(refused.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.TEMPORARY);
SmtpDeliveryClient.Result fallback = mxClient(port(), List.of("unreachable.invalid", "127.0.0.1"))
.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
assertThat(fallback.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
}
@Test
void domainWithoutMailServerIsAPermanentFailure() {
SmtpDeliveryClient.Result result = mxClient(port(), List.of())
.deliver("alice@ours.test", "bob@nowhere.test", MESSAGE.getBytes(StandardCharsets.UTF_8));
assertThat(result.outcome()).isEqualTo(SmtpDeliveryClient.Outcome.PERMANENT);
}
@Test
void dnsTroubleIsTemporary() {
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.MX,
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, port()),
domain -> { throw new SmtpDeliveryClient.TemporaryDnsException("SERVFAIL"); });
assertThat(client.deliver("a@ours.test", "b@theirs.test", MESSAGE.getBytes()).outcome())
.isEqualTo(SmtpDeliveryClient.Outcome.TEMPORARY);
}
@Test
void sendsThroughASmarthostWithLogin() {
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.SMARTHOST,
"127.0.0.1", port(), "relay-user", "relay-pass", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, 25));
assertThat(client.deliver("alice@ours.test", "bob@theirs.test", MESSAGE.getBytes()).outcome())
.isEqualTo(SmtpDeliveryClient.Outcome.DELIVERED);
assertThat(greenMail.getReceivedMessages()).hasSize(1);
}
@Test
void offModeSendsNothing() {
SmtpDeliveryClient client = new SmtpDeliveryClient(new SmtpDeliveryClient.Settings(SmtpDeliveryClient.Mode.OFF,
"", 0, "", "", SmtpDeliveryClient.Tls.NONE, "mx.ours.test", 5000, 25));
assertThat(client.deliver("a@ours.test", "b@theirs.test", MESSAGE.getBytes()).outcome())
.isEqualTo(SmtpDeliveryClient.Outcome.PERMANENT);
assertThat(greenMail.getReceivedMessages()).isEmpty();
}
}