Безопасность, 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user