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:
@@ -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@<first domain>) 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@<first domain>)
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user