From 12c2223c15eada9d91035eb6d710e120506e95e0 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Thu, 5 Nov 2026 17:43:49 +0100 Subject: [PATCH] =?UTF-8?q?75.4=20=D0=9B=D0=BE=D0=B2=D1=83=D1=88=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=87=D1=82=D1=8B:=20catch-all=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=D0=BE=D0=B2=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B5=D0=B3=D0=BE=20=D1=8F=D1=89=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0,=20=D1=84=D0=B0=D0=B9=D0=BB=20=D1=81=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 18 +++-- backend/Dockerfile | 2 +- .../mailserver/config/DataInitializer.java | 70 +++++++++++++++---- .../java/com/mailserver/smtp/MailRouter.java | 25 +++++-- .../src/main/resources/application.properties | 4 +- .../config/DataInitializerTest.java | 26 +++++++ .../com/mailserver/smtp/MailRouterTest.java | 68 ++++++++++++++++++ docker-compose.jambotron.yml | 6 +- 8 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 backend/src/test/java/com/mailserver/config/DataInitializerTest.java create mode 100644 backend/src/test/java/com/mailserver/smtp/MailRouterTest.java diff --git a/README.md b/README.md index d553dd7..1175f4e 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,9 @@ - Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`. - Вошедший пользователь пишет только от своего адреса, иначе `553`. - **Маршрут** (`MailRouter`) один для SMTP и веб-почты: - - наш домен → наш ящик, а если ящика нет — «нет такого пользователя»; - - задан `MAIL_CATCH_ALL` → всё в этот ящик; + - есть такой ящик в нашем домене → в него; + - задан `MAIL_CATCH_ALL` → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего; + - наш домен без такого ящика → «нет такого пользователя»; - чужой домен → очередь. - **Отправка наружу** (`OUTBOUND_MODE`): - `off` — ничего не уходит; @@ -53,9 +54,10 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` | | `MAIL_DOMAINS` | наши домены, через запятую | `localhost` | | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | -| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный, один раз в логе | `admin@<первый домен>` | +| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` | +| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) | | `MAIL_REGISTRATION` | открыть регистрацию | `false` | -| `MAIL_CATCH_ALL` | ящик-ловушка: вся почта сюда, наружу ничего | — | +| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | | `SMTP_PORT` | порт SMTP | `3025` | | `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` | | `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` | @@ -80,11 +82,17 @@ cd frontend && npm install && npx ng serve # http://loca В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`. - Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет. -- Первый ящик — `admin@mail.test`, пароль — в логах сервиса `mail` при первом запуске. +- Ящики `admin@mail.test` (первый) и `trap@mail.test` (ловушка) создаются при старте, их пароли — в файле `/app/data/initial-passwords.txt` контейнера `mail` (том `data`, права 600): `docker exec <контейнер mail> cat /app/data/initial-passwords.txt`. Прочитать и сменить пароль в настройках. - Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`. - Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`). - Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`). +### Ловушка почты для dev и staging (75.4) + +В `docker-compose.jambotron.yml` включена ловушка: `MAIL_CATCH_ALL=trap@mail.test`. Письмо на любой адрес, у которого здесь нет своего ящика, — `user@gmail.com`, `nobody@mail.test` — ложится в ящик `trap@mail.test`; письма между ящиками стенда идут как обычно. Наружу не уходит ничего (`OUTBOUND_MODE=off`), порт 25 и репутация IP не нужны. Выключить — `MAIL_CATCH_ALL=` (пусто). + +Чтобы staging Jambotron слал письма сюда вместо Resend, его `spring.mail` смотрит на SMTP этого сервера: хост — адрес, по которому staging видит SMTP 3025, без логина и без TLS. **Пока не сделано:** SMTP открыт только внутри ВМ, Jambotron снаружи публикует лишь HTTP-домены — нужен TCP-проброс порта до staging (решение — в Jambotron, чек-лист 75.4). + Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root. ## Не сделано diff --git a/backend/Dockerfile b/backend/Dockerfile index de7bec5..222bda2 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/src/main/java/com/mailserver/config/DataInitializer.java b/backend/src/main/java/com/mailserver/config/DataInitializer.java index f7f7af5..6423d00 100644 --- a/backend/src/main/java/com/mailserver/config/DataInitializer.java +++ b/backend/src/main/java/com/mailserver/config/DataInitializer.java @@ -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: + * + * 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); diff --git a/backend/src/main/java/com/mailserver/smtp/MailRouter.java b/backend/src/main/java/com/mailserver/smtp/MailRouter.java index 7d55efd..9578dab 100644 --- a/backend/src/main/java/com/mailserver/smtp/MailRouter.java +++ b/backend/src/main/java/com/mailserver/smtp/MailRouter.java @@ -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()); + } } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 44dcf5a..6573d18 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/backend/src/test/java/com/mailserver/config/DataInitializerTest.java b/backend/src/test/java/com/mailserver/config/DataInitializerTest.java new file mode 100644 index 0000000..a5212ad --- /dev/null +++ b/backend/src/test/java/com/mailserver/config/DataInitializerTest.java @@ -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-------"); + } +} diff --git a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java new file mode 100644 index 0000000..3b31afa --- /dev/null +++ b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java @@ -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()); + } +} diff --git a/docker-compose.jambotron.yml b/docker-compose.jambotron.yml index 4c49ffb..674daed 100644 --- a/docker-compose.jambotron.yml +++ b/docker-compose.jambotron.yml @@ -2,7 +2,8 @@ # Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain. # SMTP (3025) stays inside the VM's docker network; nothing is sent outside (OUTBOUND_MODE=off). # Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git. -# The first mailbox (admin@) and its password: in the "mail" logs on the first start. +# Mailboxes admin@ and trap@mail.test (catch-all): passwords in /app/data/initial-passwords.txt +# of the "mail" container (docker exec cat /app/data/initial-passwords.txt). name: reactive-mail services: @@ -61,7 +62,8 @@ services: MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test} MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test} DKIM_DOMAIN: ${MAIL_DOMAINS:-mail.test} - MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-} + # mail trap: mail to any address without its own mailbox here (also gmail.com etc.) goes into this mailbox + MAIL_CATCH_ALL: ${MAIL_CATCH_ALL:-trap@mail.test} MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside) OUTBOUND_MODE: "off" SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM