75.4 Ловушка почты: catch-all только для адресов без своего ящика, файл с паролями

- MailRouter: существующий ящик получает свою почту, всё остальное (и чужие домены) —
  в ящик MAIL_CATCH_ALL; раньше ловушка забирала и письма между ящиками стенда
- DataInitializer: ящик-ловушка создаётся при старте; случайные пароли — в
  MAIL_PASSWORDS_FILE (права 600, в образе /app/data/initial-passwords.txt)
- docker-compose.jambotron.yml: MAIL_CATCH_ALL=trap@mail.test по умолчанию
- README: ловушка, файл паролей, что нужно для staging Jambotron (TCP-проброс SMTP)
- тесты: MailRouterTest, DataInitializerTest (33 всего)

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:43:49 +01:00
parent db0249eb74
commit 12c2223c15
8 changed files with 192 additions and 27 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/
WORKDIR /app
COPY --from=build /app/backend/build/libs/*.jar app.jar
USER app
ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys
ENV MAIL_ATTACHMENTS_DIR=/app/data/attachments DKIM_KEYS_DIR=/app/data/dkim-keys MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt
EXPOSE 8080 3025
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
@@ -9,12 +9,27 @@ import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.Locale;
/**
* First mailbox on an empty database: {@code mailserver.admin.email} (default admin@&lt;first domain&gt;) with
* {@code mailserver.admin.password}; no password given — a random one, printed to the log once.
* Mailboxes the server needs on start:
* <ul>
* <li>the first mailbox on an empty database — {@code mailserver.admin.email} (default admin@&lt;first domain&gt;)
* with {@code mailserver.admin.password};</li>
* <li>the catch-all mailbox ({@code mailserver.catch-all}) if it does not exist yet — mail to unknown addresses is
* stored there, so it must exist.</li>
* </ul>
* A generated password is appended to {@code mailserver.passwords-file} (mode 600, in the data volume) — read it once
* and change the password. Without the file setting it is printed to the log once.
*/
@Configuration
public class DataInitializer {
@@ -24,23 +39,52 @@ public class DataInitializer {
@Bean
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
@Value("${mailserver.admin.email:}") String email,
@Value("${mailserver.admin.password:}") String password) {
@Value("${mailserver.admin.password:}") String password,
@Value("${mailserver.passwords-file:}") String passwordsFile) {
return args -> {
if (userRepository.count().block() > 0) {
return;
Path file = passwordsFile.isBlank() ? null : Path.of(passwordsFile);
if (userRepository.count().block() == 0) {
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase(Locale.ROOT);
create(userService, address, password, "Administrator", file);
}
String address = email.isBlank() ? "admin@" + domains.domains().iterator().next() : email.trim().toLowerCase();
boolean generated = password.isBlank();
String secret = generated ? randomPassword() : password;
userService.register(address, secret, "Administrator").block();
if (generated) {
log.warn("First mailbox created: {} — password: {} (shown once, change it)", address, secret);
} else {
log.info("First mailbox created: {}", address);
String catchAll = domains.catchAll();
if (catchAll != null && !Boolean.TRUE.equals(userRepository.existsByEmail(catchAll).block())) {
create(userService, catchAll, "", "Mail trap", file);
}
};
}
private static void create(UserService userService, String address, String password, String name, Path file)
throws IOException {
boolean generated = password.isBlank();
String secret = generated ? randomPassword() : password;
userService.register(address, secret, name).block();
if (!generated) {
log.info("Mailbox created: {}", address);
} else if (file != null) {
appendPassword(file, address, secret);
log.warn("Mailbox created: {} — password in {} (change it)", address, file);
} else {
log.warn("Mailbox created: {} — password: {} (shown once, change it)", address, secret);
}
}
static void appendPassword(Path file, String address, String password) throws IOException {
if (file.getParent() != null) {
Files.createDirectories(file.getParent());
}
if (!Files.exists(file)) {
Files.createFile(file);
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// not a POSIX file system (Windows) — nothing to restrict
}
}
Files.writeString(file, Instant.now() + " " + address + " " + password + System.lineSeparator(),
StandardCharsets.UTF_8, StandardOpenOption.APPEND);
}
private static String randomPassword() {
byte[] bytes = new byte[18];
new SecureRandom().nextBytes(bytes);
@@ -14,8 +14,9 @@ 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).
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
*/
@Service
public class MailRouter implements SmtpBackend {
@@ -47,12 +48,14 @@ public class MailRouter implements SmtpBackend {
@Override
public Route route(String recipient, String authenticatedUser) {
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
return Route.LOCAL;
}
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;
return Route.UNKNOWN_USER;
}
if (authenticatedUser == null) {
return Route.RELAY_DENIED;
@@ -67,7 +70,7 @@ public class MailRouter implements SmtpBackend {
for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) {
case LOCAL -> {
String mailbox = domains.catchAll() != null ? domains.catchAll() : recipient.address();
String mailbox = mailbox(recipient.address());
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
}
case OUTBOUND -> {
@@ -80,4 +83,16 @@ public class MailRouter implements SmtpBackend {
}
}
}
/** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */
String mailbox(String address) {
if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) {
return address;
}
return domains.catchAll();
}
private boolean mailboxExists(String address) {
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
}
}
@@ -21,12 +21,14 @@ spring.flyway.baseline-on-migrate=true
jwt.secret=${JWT_SECRET}
# Mailboxes of these domains live here; catch-all: every message to any address goes into this one mailbox (mail trap)
# Mailboxes of these domains live here; catch-all: mail to any address without its own mailbox here goes into this one mailbox (mail trap), nothing goes outside
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:}
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments
@@ -0,0 +1,26 @@
package com.mailserver.config;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import static org.assertj.core.api.Assertions.assertThat;
class DataInitializerTest {
@Test
void generatedPasswordsAreAppendedToAFileOnlyTheOwnerReads(@TempDir Path dir) throws Exception {
Path file = dir.resolve("data/initial-passwords.txt");
DataInitializer.appendPassword(file, "admin@mail.test", "secret-1");
DataInitializer.appendPassword(file, "trap@mail.test", "secret-2");
assertThat(Files.readAllLines(file)).hasSize(2);
assertThat(Files.readAllLines(file).get(0)).endsWith(" admin@mail.test secret-1");
assertThat(Files.readAllLines(file).get(1)).endsWith(" trap@mail.test secret-2");
assertThat(PosixFilePermissions.toString(Files.getPosixFilePermissions(file))).isEqualTo("rw-------");
}
}
@@ -0,0 +1,68 @@
package com.mailserver.smtp;
import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Mail trap (75.4): existing mailboxes keep their mail, everything else goes into the catch-all mailbox. */
class MailRouterTest {
private final UserRepository users = mock(UserRepository.class);
private final LocalDelivery localDelivery = mock(LocalDelivery.class);
private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private MailRouter router(String catchAll) {
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF);
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
queue, mock(DkimService.class), client);
}
@Test
void withoutCatchAllUnknownAndOutsideAddressesAreRefused() {
MailRouter router = router("");
assertThat(router.route("bob@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER);
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.RELAY_DENIED);
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND_DISABLED);
}
@Test
void catchAllTakesEverythingWithoutItsOwnMailboxButExistingMailboxesKeepTheirMail() throws Exception {
MailRouter router = router("trap@mail.test");
assertThat(router.route("nobody@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("user@gmail.com", null)).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.mailbox("bob@mail.test")).isEqualTo("bob@mail.test");
assertThat(router.mailbox("user@gmail.com")).isEqualTo("trap@mail.test");
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "no-reply@jambotron.com", null, List.of(
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("trap@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any());
}
}