Merge pull request '75.4 Ловушка почты: catch-all только для адресов без своего ящика, файл с паролями' (#8) from feat/75-4-mail-trap into master
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -18,8 +18,9 @@
|
|||||||
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
|
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
|
||||||
- Вошедший пользователь пишет только от своего адреса, иначе `553`.
|
- Вошедший пользователь пишет только от своего адреса, иначе `553`.
|
||||||
- **Маршрут** (`MailRouter`) один для SMTP и веб-почты:
|
- **Маршрут** (`MailRouter`) один для SMTP и веб-почты:
|
||||||
- наш домен → наш ящик, а если ящика нет — «нет такого пользователя»;
|
- есть такой ящик в нашем домене → в него;
|
||||||
- задан `MAIL_CATCH_ALL` → всё в этот ящик;
|
- задан `MAIL_CATCH_ALL` → всё остальное (и чужие домены) — в ящик-ловушку, наружу ничего;
|
||||||
|
- наш домен без такого ящика → «нет такого пользователя»;
|
||||||
- чужой домен → очередь.
|
- чужой домен → очередь.
|
||||||
- **Отправка наружу** (`OUTBOUND_MODE`):
|
- **Отправка наружу** (`OUTBOUND_MODE`):
|
||||||
- `off` — ничего не уходит;
|
- `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` |
|
| `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER` | база | `localhost`, `5432`, `mailserver`, `mail` |
|
||||||
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
||||||
| `MAIL_HOSTNAME` | имя сервера в SMTP | `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_REGISTRATION` | открыть регистрацию | `false` |
|
||||||
| `MAIL_CATCH_ALL` | ящик-ловушка: вся почта сюда, наружу ничего | — |
|
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
||||||
| `SMTP_PORT` | порт SMTP | `3025` |
|
| `SMTP_PORT` | порт SMTP | `3025` |
|
||||||
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
|
| `SMTP_AUTH_WITHOUT_TLS` | разрешить `AUTH` без шифрования — **только** в закрытой сети (docker, ВМ) | `false` |
|
||||||
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
|
| `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`.
|
В мастере деплоя выбрать `docker-compose.jambotron.yml`, домен дать сервису `mail`, порт `8080`.
|
||||||
|
|
||||||
- Пароль базы и ключ токенов создаёт контейнер `secrets-init` при первом запуске (том `secrets`) — вводить ничего не нужно, в git ничего нет.
|
- Пароль базы и ключ токенов создаёт контейнер `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`.
|
- Регистрация открыта (`MAIL_REGISTRATION=true`): любой может завести ящик в `MAIL_DOMAINS`. Наружу письма не уходят, поэтому для тестового стенда это безопасно; закрыть — `MAIL_REGISTRATION=false`.
|
||||||
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
|
- Наружу открыты только веб-почта и API. SMTP (3025) — внутри сети ВМ, наружу ничего не отправляется (`OUTBOUND_MODE=off`).
|
||||||
- Домен ящиков — `MAIL_DOMAINS` (по умолчанию `mail.test`).
|
- Домен ящиков — `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. Как: в Jambotron, карточка «Домены» проекта → «Внутренние TCP-порты» → сервис `mail`, порт `3025` (Jambotron PR #781). Адрес `vmIp:vmPort` доступен только в сети WireGuard Jambotron, из интернета — нет.
|
||||||
|
|
||||||
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
|
Образ собирается из `backend/Dockerfile` в три этапа: Angular → Spring Boot (веб-клиент внутри jar) → JRE не от root.
|
||||||
|
|
||||||
## Не сделано
|
## Не сделано
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/backend/build/libs/*.jar app.jar
|
COPY --from=build /app/backend/build/libs/*.jar app.jar
|
||||||
USER app
|
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
|
EXPOSE 8080 3025
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||||
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
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.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* First mailbox on an empty database: {@code mailserver.admin.email} (default admin@<first domain>) with
|
* Mailboxes the server needs on start:
|
||||||
* {@code mailserver.admin.password}; no password given — a random one, printed to the log once.
|
* <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
|
@Configuration
|
||||||
public class DataInitializer {
|
public class DataInitializer {
|
||||||
@@ -24,23 +39,52 @@ public class DataInitializer {
|
|||||||
@Bean
|
@Bean
|
||||||
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
|
public CommandLineRunner initDatabase(UserService userService, UserRepository userRepository, MailDomains domains,
|
||||||
@Value("${mailserver.admin.email:}") String email,
|
@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 -> {
|
return args -> {
|
||||||
if (userRepository.count().block() > 0) {
|
Path file = passwordsFile.isBlank() ? null : Path.of(passwordsFile);
|
||||||
return;
|
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();
|
String catchAll = domains.catchAll();
|
||||||
boolean generated = password.isBlank();
|
if (catchAll != null && !Boolean.TRUE.equals(userRepository.existsByEmail(catchAll).block())) {
|
||||||
String secret = generated ? randomPassword() : password;
|
create(userService, catchAll, "", "Mail trap", file);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
private static String randomPassword() {
|
||||||
byte[] bytes = new byte[18];
|
byte[] bytes = new byte[18];
|
||||||
new SecureRandom().nextBytes(bytes);
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ import java.util.Locale;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Where mail goes — one rule for SMTP and for the web client:
|
* 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;
|
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
|
||||||
* someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
|
* (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
|
@Service
|
||||||
public class MailRouter implements SmtpBackend {
|
public class MailRouter implements SmtpBackend {
|
||||||
@@ -47,12 +48,14 @@ public class MailRouter implements SmtpBackend {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Route route(String recipient, String authenticatedUser) {
|
public Route route(String recipient, String authenticatedUser) {
|
||||||
|
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
|
||||||
|
return Route.LOCAL;
|
||||||
|
}
|
||||||
if (domains.catchAll() != null) {
|
if (domains.catchAll() != null) {
|
||||||
return Route.LOCAL;
|
return Route.LOCAL;
|
||||||
}
|
}
|
||||||
if (domains.isLocal(recipient)) {
|
if (domains.isLocal(recipient)) {
|
||||||
return Boolean.TRUE.equals(users.existsByEmail(recipient.toLowerCase(Locale.ROOT)).block())
|
return Route.UNKNOWN_USER;
|
||||||
? Route.LOCAL : Route.UNKNOWN_USER;
|
|
||||||
}
|
}
|
||||||
if (authenticatedUser == null) {
|
if (authenticatedUser == null) {
|
||||||
return Route.RELAY_DENIED;
|
return Route.RELAY_DENIED;
|
||||||
@@ -67,7 +70,7 @@ public class MailRouter implements SmtpBackend {
|
|||||||
for (Recipient recipient : envelope.recipients()) {
|
for (Recipient recipient : envelope.recipients()) {
|
||||||
switch (recipient.route()) {
|
switch (recipient.route()) {
|
||||||
case LOCAL -> {
|
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);
|
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
|
||||||
}
|
}
|
||||||
case OUTBOUND -> {
|
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}
|
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.domains=${MAIL_DOMAINS:localhost}
|
||||||
mailserver.catch-all=${MAIL_CATCH_ALL:}
|
mailserver.catch-all=${MAIL_CATCH_ALL:}
|
||||||
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
||||||
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
||||||
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
|
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:}
|
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||||
|
|
||||||
# Attachments
|
# 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
# Outside, only the web client + API (service "mail", port 8080) through Jambotron's Caddy — give it a domain.
|
# 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).
|
# 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.
|
# Secrets are generated on the first start into the "secrets" volume — nothing to type in, nothing in git.
|
||||||
# The first mailbox (admin@<MAIL_DOMAINS>) and its password: in the "mail" logs on the first start.
|
# Mailboxes admin@<MAIL_DOMAINS> and trap@mail.test (catch-all): passwords in /app/data/initial-passwords.txt
|
||||||
|
# of the "mail" container (docker exec <mail container> cat /app/data/initial-passwords.txt).
|
||||||
name: reactive-mail
|
name: reactive-mail
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -61,7 +62,8 @@ services:
|
|||||||
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
|
MAIL_DOMAINS: ${MAIL_DOMAINS:-mail.test}
|
||||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.test}
|
||||||
DKIM_DOMAIN: ${MAIL_DOMAINS:-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)
|
MAIL_REGISTRATION: ${MAIL_REGISTRATION:-true} # test stand: anyone can create a mailbox in MAIL_DOMAINS (nothing goes outside)
|
||||||
OUTBOUND_MODE: "off"
|
OUTBOUND_MODE: "off"
|
||||||
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
|
SMTP_AUTH_WITHOUT_TLS: "true" # SMTP is not reachable from outside the VM
|
||||||
|
|||||||
Reference in New Issue
Block a user