Безопасность, 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,18 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MailControllerTest {
|
||||
|
||||
@Test
|
||||
void attachmentNameCannotLeaveTheFolder() {
|
||||
assertThat(MailController.safeFilename("../../etc/passwd")).isEqualTo("passwd");
|
||||
assertThat(MailController.safeFilename("..\\..\\boot.ini")).isEqualTo("boot.ini");
|
||||
assertThat(MailController.safeFilename("..")).isEqualTo("file");
|
||||
assertThat(MailController.safeFilename("a\"b\r\n.txt")).isEqualTo("ab.txt");
|
||||
assertThat(MailController.safeFilename(null)).isEqualTo("file");
|
||||
assertThat(MailController.safeFilename("report 2026.pdf")).isEqualTo("report 2026.pdf");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DkimServiceTest {
|
||||
|
||||
private static final String MESSAGE = "From: Alice <alice@ours.test>\r\nTo: bob@theirs.test\r\nSubject: Hello\r\n"
|
||||
+ "Date: Mon, 2 Nov 2026 10:00:00 +0000\r\nMessage-ID: <1@ours.test>\r\nMIME-Version: 1.0\r\n"
|
||||
+ "Content-Type: text/plain; charset=UTF-8\r\n\r\nHello Bob,\r\nsee you.\r\n";
|
||||
|
||||
@TempDir
|
||||
Path keys;
|
||||
|
||||
/** Signer and a verifier that finds the signer's DNS record — like the receiving side would. */
|
||||
private DkimService[] pair() {
|
||||
DkimService signer = new DkimService("ours.test", "mail", keys, true, (method, selector, token) -> List.of());
|
||||
signer.init();
|
||||
DkimService verifier = new DkimService("other.test", "mail", keys.resolve("verifier"), false,
|
||||
(method, selector, token) -> "mail".contentEquals(selector) && "ours.test".contentEquals(token)
|
||||
? List.of(signer.dnsRecord()) : List.of());
|
||||
return new DkimService[]{signer, verifier};
|
||||
}
|
||||
|
||||
@Test
|
||||
void signatureFromOurDomainVerifies() throws Exception {
|
||||
DkimService[] p = pair();
|
||||
byte[] signed = p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
String text = new String(signed, StandardCharsets.UTF_8);
|
||||
assertThat(text).startsWith("DKIM-Signature:").contains("c=relaxed/relaxed").contains("a=rsa-sha256")
|
||||
.contains("d=ours.test").contains("s=mail");
|
||||
DkimService.Verification result = p[1].verify(signed);
|
||||
assertThat(result.signed()).isTrue();
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.domain()).isEqualTo("ours.test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void changedBodyFailsVerification() throws Exception {
|
||||
DkimService[] p = pair();
|
||||
String signed = new String(p[0].sign(MESSAGE.getBytes(StandardCharsets.UTF_8)), StandardCharsets.UTF_8);
|
||||
byte[] tampered = signed.replace("see you.", "send money.").getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
DkimService.Verification result = p[1].verify(tampered);
|
||||
assertThat(result.signed()).isTrue();
|
||||
assertThat(result.passed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsignedMessageIsReportedAsNone() {
|
||||
DkimService.Verification result = pair()[1].verify(MESSAGE.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(result.signed()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mailFromAnotherDomainIsNotSigned() throws Exception {
|
||||
byte[] foreign = MESSAGE.replace("alice@ours.test", "eve@elsewhere.test").getBytes(StandardCharsets.UTF_8);
|
||||
assertThat(pair()[0].sign(foreign)).isEqualTo(foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyIsKeptAcrossRestartsAndOldPkcs1FilesLoad() throws Exception {
|
||||
DkimService first = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||
first.init();
|
||||
DkimService second = new DkimService("ours.test", "mail", keys, true, (m, s, t) -> List.of());
|
||||
second.init();
|
||||
assertThat(second.dnsRecord()).isEqualTo(first.dnsRecord()).startsWith("v=DKIM1; k=rsa; p=");
|
||||
|
||||
// the previous version wrote "BEGIN RSA PRIVATE KEY" (PKCS#1) and could not read it back
|
||||
Path legacy = keys.resolve("legacy");
|
||||
Files.createDirectories(legacy);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
RSAPrivateKey key = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||
try (var writer = Files.newBufferedWriter(legacy.resolve("dkim-private.pem"));
|
||||
var pem = new org.bouncycastle.openssl.jcajce.JcaPEMWriter(writer)) {
|
||||
pem.writeObject(key);
|
||||
}
|
||||
assertThat(Files.readString(legacy.resolve("dkim-private.pem"))).contains("BEGIN RSA PRIVATE KEY");
|
||||
DkimService loaded = new DkimService("ours.test", "mail", legacy, true, (m, s, t) -> List.of());
|
||||
loaded.init();
|
||||
assertThat(loaded.dnsRecord()).isNotNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Properties;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LocalDeliveryTest {
|
||||
|
||||
private static MimeMessage parse(String text) throws Exception {
|
||||
return new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefersHtmlInNestedAlternativeAndSkipsAttachments() throws Exception {
|
||||
MimeMessage message = parse("""
|
||||
Content-Type: multipart/mixed; boundary="m"\r
|
||||
\r
|
||||
--m\r
|
||||
Content-Type: multipart/alternative; boundary="a"\r
|
||||
\r
|
||||
--a\r
|
||||
Content-Type: text/plain\r
|
||||
\r
|
||||
plain\r
|
||||
--a\r
|
||||
Content-Type: text/html\r
|
||||
\r
|
||||
<p>html</p>\r
|
||||
--a--\r
|
||||
--m\r
|
||||
Content-Type: text/html\r
|
||||
Content-Disposition: attachment; filename="x.html"\r
|
||||
\r
|
||||
<p>attached</p>\r
|
||||
--m--\r
|
||||
""");
|
||||
|
||||
LocalDelivery.Body body = LocalDelivery.body(message);
|
||||
assertThat(body.html()).isTrue();
|
||||
assertThat(body.text()).contains("<p>html</p>").doesNotContain("attached");
|
||||
}
|
||||
|
||||
@Test
|
||||
void plainTextMessage() throws Exception {
|
||||
LocalDelivery.Body body = LocalDelivery.body(parse("Subject: x\r\n\r\njust text\r\n"));
|
||||
assertThat(body.html()).isFalse();
|
||||
assertThat(body.text()).isEqualTo("just text\r\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package com.mailserver.smtp;
|
||||
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.Transport;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
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.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class SmtpServerTest {
|
||||
|
||||
/** Mailboxes of example.test; alice can log in. */
|
||||
static class FakeBackend implements SmtpBackend {
|
||||
final Map<String, String> passwords = Map.of("alice@example.test", "secret");
|
||||
final List<String> mailboxes = List.of("alice@example.test", "bob@example.test");
|
||||
final List<Envelope> envelopes = new CopyOnWriteArrayList<>();
|
||||
final List<String> messages = new CopyOnWriteArrayList<>();
|
||||
volatile boolean failStore;
|
||||
|
||||
@Override
|
||||
public boolean authenticate(String username, String password) {
|
||||
return password.equals(passwords.get(username));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Route route(String recipient, String authenticatedUser) {
|
||||
if (recipient.endsWith("@example.test")) {
|
||||
return mailboxes.contains(recipient) ? Route.LOCAL : Route.UNKNOWN_USER;
|
||||
}
|
||||
return authenticatedUser == null ? Route.RELAY_DENIED : Route.OUTBOUND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void accept(Envelope envelope, byte[] message) {
|
||||
if (failStore) {
|
||||
throw new IllegalStateException("database down");
|
||||
}
|
||||
envelopes.add(envelope);
|
||||
messages.add(new String(message, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private FakeBackend backend;
|
||||
private SmtpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void start() {
|
||||
backend = new FakeBackend();
|
||||
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, true,
|
||||
Duration.ofSeconds(30)), backend);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
server.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesMailFromARealClient() throws Exception {
|
||||
Properties props = new Properties();
|
||||
props.put("mail.smtp.host", "127.0.0.1");
|
||||
props.put("mail.smtp.port", String.valueOf(server.port()));
|
||||
Session session = Session.getInstance(props);
|
||||
MimeMessage message = new MimeMessage(session);
|
||||
message.setFrom(new InternetAddress("someone@elsewhere.test"));
|
||||
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
|
||||
message.setSubject("Hello");
|
||||
message.setText("first line\n.starts with a dot\nlast line");
|
||||
|
||||
Transport.send(message);
|
||||
|
||||
assertThat(backend.envelopes).hasSize(1);
|
||||
SmtpBackend.Envelope envelope = backend.envelopes.get(0);
|
||||
assertThat(envelope.mailFrom()).isEqualTo("someone@elsewhere.test");
|
||||
assertThat(envelope.authenticatedUser()).isNull();
|
||||
assertThat(envelope.recipients()).containsExactly(new SmtpBackend.Recipient("bob@example.test", SmtpBackend.Route.LOCAL));
|
||||
assertThat(backend.messages.get(0))
|
||||
.startsWith("Received: from ")
|
||||
.contains("by mx.example.test with ESMTP;")
|
||||
.contains("Subject: Hello")
|
||||
.contains("\r\n.starts with a dot\r\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handlesPipelinedCommandsInOnePacketAndLinesSplitAcrossPackets() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.write("EHLO c.test\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\nDATA\r\n");
|
||||
assertThat(client.readReply()).startsWith("250-mx.example.test");
|
||||
assertThat(client.read()).startsWith("250 2.1.0");
|
||||
assertThat(client.read()).startsWith("250 2.1.5");
|
||||
assertThat(client.read()).startsWith("354");
|
||||
client.write("Subject: split\r\n\r\nhal");
|
||||
client.write("f a line\r\n..dot\r\n.");
|
||||
client.write("\r\nQUIT\r\n");
|
||||
assertThat(client.read()).startsWith("250 2.0.0");
|
||||
assertThat(client.read()).startsWith("221");
|
||||
}
|
||||
assertThat(backend.messages.get(0)).endsWith("Subject: split\r\n\r\nhalf a line\r\n.dot\r\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesToRelayForStrangers() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO spammer.test");
|
||||
client.readReply();
|
||||
client.send("MAIL FROM:<spam@spammer.test>");
|
||||
assertThat(client.send("RCPT TO:<victim@gmail.com>")).startsWith("550 5.7.1 Relaying denied");
|
||||
assertThat(client.send("RCPT TO:<nobody@example.test>")).startsWith("550 5.1.1");
|
||||
assertThat(client.send("DATA")).startsWith("554");
|
||||
}
|
||||
assertThat(backend.envelopes).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void relaysForALoggedInUserOnlyFromHisOwnAddress() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
assertThat(client.readReplyLines()).contains("250-AUTH PLAIN LOGIN");
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0wrong"))).startsWith("535");
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0Alice@Example.test\0secret"))).startsWith("235");
|
||||
assertThat(client.send("MAIL FROM:<bob@example.test>")).startsWith("553");
|
||||
assertThat(client.send("MAIL FROM:<alice@example.test>")).startsWith("250");
|
||||
assertThat(client.send("RCPT TO:<friend@gmail.com>")).startsWith("250");
|
||||
assertThat(client.send("DATA")).startsWith("354");
|
||||
assertThat(client.send("Subject: out\r\n\r\nhi\r\n.")).startsWith("250");
|
||||
}
|
||||
SmtpBackend.Envelope envelope = backend.envelopes.get(0);
|
||||
assertThat(envelope.authenticatedUser()).isEqualTo("alice@example.test");
|
||||
assertThat(envelope.recipients()).containsExactly(new SmtpBackend.Recipient("friend@gmail.com", SmtpBackend.Route.OUTBOUND));
|
||||
assertThat(backend.messages.get(0)).contains("with ESMTPA;");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authLoginWorksToo() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
client.readReply();
|
||||
assertThat(client.send("AUTH LOGIN")).isEqualTo("334 " + b64("Username:"));
|
||||
assertThat(client.send(b64("alice@example.test"))).isEqualTo("334 " + b64("Password:"));
|
||||
assertThat(client.send(b64("secret"))).startsWith("235");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTooBigMessagesAndKeepsTheSession() throws Exception {
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("HELO c");
|
||||
client.send("MAIL FROM:<x@elsewhere.test>");
|
||||
client.send("RCPT TO:<bob@example.test>");
|
||||
client.send("DATA");
|
||||
assertThat(client.send("x".repeat(3000) + "\r\n.")).startsWith("552");
|
||||
assertThat(client.send("MAIL FROM:<x@elsewhere.test> SIZE=5000")).startsWith("552");
|
||||
assertThat(client.send("NOOP")).startsWith("250");
|
||||
}
|
||||
assertThat(backend.envelopes).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void answers451WhenTheMessageCannotBeStored() throws Exception {
|
||||
backend.failStore = true;
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("HELO c");
|
||||
client.send("MAIL FROM:<x@elsewhere.test>");
|
||||
client.send("RCPT TO:<bob@example.test>");
|
||||
client.send("DATA");
|
||||
assertThat(client.send("Subject: x\r\n\r\nbody\r\n.")).startsWith("451");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutTlsAuthIsOffWhenNotAllowed() throws Exception {
|
||||
server.stop();
|
||||
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
|
||||
Duration.ofSeconds(30)), backend);
|
||||
server.start();
|
||||
try (Client client = new Client(server.port())) {
|
||||
client.read();
|
||||
client.send("EHLO laptop");
|
||||
assertThat(client.readReplyLines()).noneMatch(line -> line.contains("AUTH"));
|
||||
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesPaths() {
|
||||
assertThat(SmtpServer.path("<A@B.test> SIZE=10")).isEqualTo("a@b.test");
|
||||
assertThat(SmtpServer.path("<>")).isEmpty();
|
||||
assertThat(SmtpServer.path("<@relay.test:u@b.test>")).isEqualTo("u@b.test");
|
||||
assertThat(SmtpServer.path("u@b.test")).isNull();
|
||||
assertThat(SmtpServer.path("<a b@c>")).isNull();
|
||||
}
|
||||
|
||||
private static String b64(String text) {
|
||||
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** Line-oriented test client. */
|
||||
static final class Client implements AutoCloseable {
|
||||
private final Socket socket;
|
||||
private final BufferedReader in;
|
||||
private final OutputStream out;
|
||||
|
||||
Client(int port) throws Exception {
|
||||
socket = new Socket("127.0.0.1", port);
|
||||
socket.setSoTimeout(5000);
|
||||
in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
out = socket.getOutputStream();
|
||||
}
|
||||
|
||||
void write(String text) throws Exception {
|
||||
out.write(text.getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
Thread.sleep(50); // separate TCP packets
|
||||
}
|
||||
|
||||
String send(String line) throws Exception {
|
||||
out.write((line + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
out.flush();
|
||||
return read();
|
||||
}
|
||||
|
||||
String read() throws Exception {
|
||||
return in.readLine();
|
||||
}
|
||||
|
||||
/** First line of a multi-line reply; the rest is consumed. */
|
||||
String readReply() throws Exception {
|
||||
return readReplyLines().get(0);
|
||||
}
|
||||
|
||||
List<String> readReplyLines() throws Exception {
|
||||
List<String> lines = new ArrayList<>();
|
||||
String line;
|
||||
do {
|
||||
line = in.readLine();
|
||||
lines.add(line);
|
||||
} while (line != null && line.length() > 3 && line.charAt(3) == '-');
|
||||
return lines;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user