Compare commits

...
Author SHA1 Message Date
Liosha84 17d489e400 75.1 TLS для SMTP: STARTTLS и неявный TLS (465)
- SmtpServer: STARTTLS (RFC 3207) — SslHandler ставится сразу после того,
  как ответ 220 ушёл открытым текстом; сессия начинается заново; строки,
  присланные открытым текстом вместе со STARTTLS, не выполняются, соединение
  закрывается (CVE-2011-0411); свежий декодер строк без остатков открытого текста
- неявный TLS на SMTPS_PORT (RFC 8314); AUTH — только внутри TLS
  (или SMTP_AUTH_WITHOUT_TLS); Received: ESMTPS/ESMTPSA
- SmtpTls: свой сертификат (MAIL_TLS_CERT/KEY) или самоподписанный,
  создаётся один раз в MAIL_TLS_DIR (в образе /app/data/tls); TLS 1.2/1.3
- тесты: STARTTLS + AUTH, подмешанный открытый текст, JavaMail со
  starttls.required, порт 465, переиспользование сертификата (38 всего)
- проверено вживую: openssl s_client -starttls smtp и порт 465, TLSv1.3

α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:42:10 +01:00
Liosha84 37f6b3ef3d Merge pull request '75.4 Ловушка почты: catch-all только для адресов без своего ящика, файл с паролями' (#8) from feat/75-4-mail-trap into master
Reviewed-on: #8
2026-11-05 18:14:23 +01:00
Liosha84andClaude Opus 5.5 69b62b5769 README: SMTP ловушки для staging — через внутренний TCP-порт Jambotron (#781)
α
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:10:38 +01:00
Liosha84 12c2223c15 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>
2026-11-05 17:43:49 +01:00
Liosha84 db0249eb74 Merge pull request 'Почтовый кабинет: регистрация, папки, черновики, вложения, грид, synthwave-интерфейс' (#7) from feat/mail-cabinet into master
Reviewed-on: #7
2026-11-05 16:43:54 +01:00
12 changed files with 622 additions and 50 deletions
+20 -6
View File
@@ -13,13 +13,15 @@
``` ```
- **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке. - **Приём (SMTP).** Команды разбираются по строкам (`LineBasedFrameDecoder`): работает и пачка команд в одном пакете, и строка, разорванная на два пакета. Письмо сохраняется или ставится в очередь **до** ответа `250`; если сохранить не удалось — `451`, и отправитель повторит. Работа с базой и DNS идёт не в сетевом потоке.
- **TLS.** `STARTTLS` на порту SMTP (RFC 3207) и, если задан `SMTPS_PORT`, неявный TLS (RFC 8314, обычно 465); TLS 1.2/1.3. После `STARTTLS` сессия начинается заново (новый `EHLO`), а команды, присланные открытым текстом вместе со `STARTTLS`, не выполняются — соединение закрывается (CVE-2011-0411). `AUTH` предлагается только внутри TLS (или без него при `SMTP_AUTH_WITHOUT_TLS=true`). Сертификат — свой (`MAIL_TLS_CERT` + `MAIL_TLS_KEY`, например Let's Encrypt) или самоподписанный: создаётся один раз в `MAIL_TLS_DIR` и потом переиспользуется.
- **Не открытый релей.** - **Не открытый релей.**
- Письмо на свой домен принимается от кого угодно. - Письмо на свой домен принимается от кого угодно.
- Письмо на чужой домен — только после `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,10 +55,15 @@ 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` |
| `MAIL_TLS` | TLS для SMTP: `STARTTLS` и порт `SMTPS_PORT` | `true` |
| `MAIL_TLS_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ PKCS#8; без них — самоподписанный | — |
| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) |
| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` |
| `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` |
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` | | `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
@@ -80,16 +87,23 @@ 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.
## Не сделано ## Не сделано
- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети. - [ ] TLS для IMAP (вместе с переписыванием IMAP). SMTP — сделано: `STARTTLS` и порт 465.
- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird. - [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново. - [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма. - [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
+2 -2
View File
@@ -24,11 +24,11 @@ RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test
FROM eclipse-temurin:21-jre FROM eclipse-temurin:21-jre
# Ubuntu image, curl included; own user: "mail" already exists (uid 8) # Ubuntu image, curl included; own user: "mail" already exists (uid 8)
RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \ RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app -d /app -s /usr/sbin/nologin app \
&& mkdir -p /app/data/attachments /app/data/dkim-keys && chown -R app:app /app/data && mkdir -p /app/data/attachments /app/data/dkim-keys /app/data/tls && chown -R app:app /app/data
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 MAIL_TLS_DIR=/app/data/tls
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@&lt;first domain&gt;) 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@&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 @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);
@@ -3,12 +3,16 @@ package com.mailserver.config;
import com.mailserver.delivery.SmtpDeliveryClient; import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter; import com.mailserver.smtp.MailRouter;
import com.mailserver.smtp.SmtpServer; import com.mailserver.smtp.SmtpServer;
import com.mailserver.smtp.SmtpTls;
import io.netty.handler.ssl.SslContext;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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 org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.util.Locale; import java.util.Locale;
@@ -17,16 +21,46 @@ import java.util.Locale;
@EnableScheduling @EnableScheduling
public class MailServerConfig { public class MailServerConfig {
/**
* Certificate for SMTP TLS: mailserver.tls.cert-file + key-file (PEM), or a self-signed one in mailserver.tls.dir.
* mailserver.tls.enabled=false — no TLS at all.
*/
@Bean
public SmtpTlsContext smtpTlsContext(@Value("${mailserver.tls.enabled:true}") boolean enabled,
@Value("${mailserver.tls.cert-file:}") String certFile,
@Value("${mailserver.tls.key-file:}") String keyFile,
@Value("${mailserver.tls.dir:./tls}") String dir,
@Value("${mailserver.smtp.hostname:localhost}") String hostname) throws Exception {
return new SmtpTlsContext(enabled ? SmtpTls.serverContext(certFile, keyFile, Path.of(dir), hostname) : null);
}
public record SmtpTlsContext(SslContext context) {
}
@Bean(initMethod = "start", destroyMethod = "stop") @Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "mailserver.smtp.enabled", havingValue = "true", matchIfMissing = true)
public SmtpServer smtpServer(MailRouter router, public SmtpServer smtpServer(MailRouter router, SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host, @Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtp.port:3025}") int port, @Value("${mailserver.smtp.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname, @Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes, @Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls, @Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) { @Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout), router); return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, authWithoutTls, idleTimeout,
tls.context(), false), router);
}
/** Implicit TLS (RFC 8314, usually port 465) — only when mailserver.smtps.port is set and TLS is on. */
@Bean(initMethod = "start", destroyMethod = "stop")
@ConditionalOnExpression("${mailserver.smtps.port:0} > 0 and ${mailserver.tls.enabled:true}")
public SmtpServer smtpsServer(MailRouter router, SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.smtps.port}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.idle-timeout:5m}") Duration idleTimeout) {
return new SmtpServer(new SmtpServer.Settings(host, port, hostname, maxBytes, false, idleTimeout,
tls.context(), true), router);
} }
/** /**
@@ -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());
}
} }
@@ -1,13 +1,22 @@
package com.mailserver.smtp; package com.mailserver.smtp;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.LineBasedFrameDecoder; import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.MessageToMessageDecoder;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutHandler; import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import reactor.netty.Connection;
import reactor.netty.DisposableServer; import reactor.netty.DisposableServer;
import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer; import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@@ -32,7 +41,10 @@ import java.util.concurrent.TimeUnit;
* <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li> * <li>the message is stored or queued before the client gets 250 — on failure 451 and the client retries;</li>
* <li>database and DNS work runs on a worker thread, not on the event loop.</li> * <li>database and DNS work runs on a worker thread, not on the event loop.</li>
* </ul> * </ul>
* No STARTTLS yet: AUTH is offered only when {@code authWithoutTls} is on (a closed network: inside a VM or a docker network). * TLS (RFC 3207 STARTTLS, or implicit TLS on a separate port, RFC 8314): with a {@code tls} context STARTTLS is
* offered; AUTH only over TLS, or without it when {@code authWithoutTls} is on (a closed network: VM, docker network).
* After STARTTLS the session starts over (new EHLO), and lines sent in plain text together with STARTTLS are not
* executed — the connection is closed (command injection, CVE-2011-0411).
*/ */
public class SmtpServer { public class SmtpServer {
@@ -42,9 +54,32 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024; static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100; static final int MAX_RECIPIENTS = 100;
private static final int MAX_BAD_COMMANDS = 20; private static final int MAX_BAD_COMMANDS = 20;
// Compared by identity: TLS starts right after exactly this reply is written
private static final String STARTTLS_READY = new String("220 2.0.0 Ready to start TLS\r\n");
/**
* @param tls certificate for STARTTLS / implicit TLS; null — no TLS
* @param implicitTls TLS from the first byte (port 465), no STARTTLS
*/
public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls, public record Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) { Duration idleTimeout, SslContext tls, boolean implicitTls) {
public Settings(String host, int port, String hostname, long maxMessageBytes, boolean authWithoutTls,
Duration idleTimeout) {
this(host, port, hostname, maxMessageBytes, authWithoutTls, idleTimeout, null, false);
}
}
/** A line of input and whether it came over TLS. */
record Line(byte[] bytes, boolean tls) {
}
/** Marks each line: decoded behind an SslHandler (encrypted) or not. */
private static final class LineTagger extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List<Object> out) {
out.add(new Line(ByteBufUtil.getBytes(frame), ctx.pipeline().get(SslHandler.class) != null));
}
} }
private final Settings settings; private final Settings settings;
@@ -57,32 +92,70 @@ public class SmtpServer {
} }
public void start() { public void start() {
server = TcpServer.create() if (settings.implicitTls() && settings.tls() == null) {
throw new IllegalStateException("Implicit TLS needs a certificate");
}
TcpServer tcp = TcpServer.create()
.host(settings.host()) .host(settings.host())
.port(settings.port()) .port(settings.port())
.doOnConnection(connection -> connection .doOnConnection(connection -> connection
.addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS)) .addHandlerFirst("smtp-idle", new ReadTimeoutHandler(settings.idleTimeout().toSeconds(), TimeUnit.SECONDS))
.addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))) .addHandlerLast("smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false))
.addHandlerLast("smtp-tag", new LineTagger()));
if (settings.implicitTls()) {
tcp = tcp.secure(spec -> spec.sslContext(settings.tls()));
}
server = tcp
.handle((inbound, outbound) -> { .handle((inbound, outbound) -> {
Connection[] connection = {null};
String[] remoteIp = {"unknown"}; String[] remoteIp = {"unknown"};
inbound.withConnection(c -> { inbound.withConnection(c -> {
connection[0] = c;
if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) { if (c.address() instanceof InetSocketAddress address && address.getAddress() != null) {
remoteIp[0] = address.getAddress().getHostAddress(); remoteIp[0] = address.getAddress().getHostAddress();
} }
}); });
Session session = new Session(remoteIp[0]); Session session = new Session(remoteIp[0], settings.implicitTls());
Flux<String> replies = inbound.receive().asByteArray() Flux<String> replies = inbound.receiveObject().cast(Line.class)
.concatMap(session::onLine) .concatMap(session::onLine)
.onErrorResume(e -> { .onErrorResume(e -> {
log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString()); log.debug("SMTP {}: connection closed: {}", session.remoteIp, e.toString());
return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n"); return Mono.just("421 4.4.2 " + settings.hostname() + " closing connection\r\n");
}) })
.takeUntil(reply -> session.closing); .takeUntil(reply -> session.closing);
return outbound.sendString(Flux.concat( // One reply at a time, each written before the next line is handled: after "220 Ready to start TLS"
Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies), StandardCharsets.US_ASCII); // the TLS handler goes in only when the reply has left in plain text
return Flux.concat(Mono.just("220 " + settings.hostname() + " ESMTP ready\r\n"), replies)
.concatMap(reply -> send(outbound, reply)
.then(Mono.defer(() -> reply == STARTTLS_READY ? startTls(connection[0], session) : Mono.<Void>empty())))
.then();
}) })
.bindNow(Duration.ofSeconds(10)); .bindNow(Duration.ofSeconds(10));
log.info("SMTP server listening on {}:{} ({})", settings.host(), port(), settings.hostname()); log.info("SMTP server listening on {}:{} ({}{})", settings.host(), port(), settings.hostname(),
settings.implicitTls() ? ", implicit TLS" : settings.tls() != null ? ", STARTTLS" : ", no TLS");
}
private static Mono<Void> send(NettyOutbound outbound, String reply) {
return outbound.sendString(Mono.just(reply), StandardCharsets.US_ASCII).then();
}
/** TLS handler in front, fresh line decoder (no plain-text leftovers mixed into encrypted input). */
private Mono<Void> startTls(Connection connection, Session session) {
ChannelPipeline pipeline = connection.channel().pipeline();
SslHandler ssl = settings.tls().newHandler(connection.channel().alloc());
pipeline.replace("smtp-lines", "smtp-lines", new LineBasedFrameDecoder(MAX_LINE, true, false));
pipeline.addFirst("smtp-tls", ssl);
return Mono.create(sink -> ssl.handshakeFuture().addListener(future -> {
if (future.isSuccess()) {
log.debug("SMTP {}: TLS {}", session.remoteIp, ssl.engine().getSession().getProtocol());
sink.success();
} else {
log.info("SMTP {}: TLS handshake failed: {}", session.remoteIp, future.cause().toString());
session.closing = true;
connection.dispose();
sink.success();
}
}));
} }
public int port() { public int port() {
@@ -99,6 +172,7 @@ public class SmtpServer {
private final class Session { private final class Session {
final String remoteIp; final String remoteIp;
boolean tls;
State state = State.COMMAND; State state = State.COMMAND;
String helo; String helo;
boolean extended; boolean extended;
@@ -111,12 +185,17 @@ public class SmtpServer {
int badCommands; int badCommands;
volatile boolean closing; volatile boolean closing;
Session(String remoteIp) { Session(String remoteIp, boolean tls) {
this.remoteIp = remoteIp; this.remoteIp = remoteIp;
this.tls = tls;
} }
Mono<String> onLine(byte[] raw) { Mono<String> onLine(Line input) {
byte[] line = stripCr(raw); if (tls && !input.tls()) {
// Sent in plain text after STARTTLS, before the handshake: never execute it
return Mono.error(new IllegalStateException("plain-text input after STARTTLS"));
}
byte[] line = stripCr(input.bytes());
return switch (state) { return switch (state) {
case DATA -> onDataLine(line); case DATA -> onDataLine(line);
case AUTH_PLAIN -> authPlain(ascii(line)); case AUTH_PLAIN -> authPlain(ascii(line));
@@ -142,7 +221,7 @@ public class SmtpServer {
} }
case "NOOP" -> reply("250 2.0.0 OK"); case "NOOP" -> reply("250 2.0.0 OK");
case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message"); case "VRFY" -> reply("252 2.5.0 Cannot VRFY user, but will accept message");
case "STARTTLS" -> reply("454 4.7.0 TLS not available"); case "STARTTLS" -> startTls(arg);
case "QUIT" -> { case "QUIT" -> {
closing = true; closing = true;
yield reply("221 2.0.0 " + settings.hostname() + " Bye"); yield reply("221 2.0.0 " + settings.hostname() + " Bye");
@@ -167,15 +246,41 @@ public class SmtpServer {
sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n"); sb.append("250-SIZE ").append(settings.maxMessageBytes()).append("\r\n");
sb.append("250-8BITMIME\r\n"); sb.append("250-8BITMIME\r\n");
sb.append("250-PIPELINING\r\n"); sb.append("250-PIPELINING\r\n");
if (settings.authWithoutTls()) { if (settings.tls() != null && !tls) {
sb.append("250-STARTTLS\r\n");
}
if (authAllowed()) {
sb.append("250-AUTH PLAIN LOGIN\r\n"); sb.append("250-AUTH PLAIN LOGIN\r\n");
} }
sb.append("250 ENHANCEDSTATUSCODES\r\n"); sb.append("250 ENHANCEDSTATUSCODES\r\n");
return Mono.just(sb.toString()); return Mono.just(sb.toString());
} }
private boolean authAllowed() {
return tls || settings.authWithoutTls();
}
private Mono<String> startTls(String arg) {
if (settings.tls() == null) {
return reply("454 4.7.0 TLS not available");
}
if (tls) {
return bad("503 5.5.1 TLS already active");
}
if (!arg.isEmpty()) {
return reply("501 5.5.4 Syntax: STARTTLS");
}
// RFC 3207 4.2: everything known about the client is forgotten, a new EHLO is expected
tls = true;
helo = null;
extended = false;
authenticatedUser = null;
resetTransaction();
return Mono.just(STARTTLS_READY);
}
private Mono<String> auth(String arg) { private Mono<String> auth(String arg) {
if (!settings.authWithoutTls()) { if (!authAllowed()) {
return reply("538 5.7.11 Encryption required for requested authentication mechanism"); return reply("538 5.7.11 Encryption required for requested authentication mechanism");
} }
if (helo == null || !extended) { if (helo == null || !extended) {
@@ -369,7 +474,7 @@ public class SmtpServer {
private byte[] withReceivedHeader(byte[] message) { private byte[] withReceivedHeader(byte[] message) {
String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n" String received = "Received: from " + helo + " ([" + remoteIp + "])\r\n"
+ "\tby " + settings.hostname() + " with " + (authenticatedUser != null ? "ESMTPA" : "ESMTP") + ";\r\n" + "\tby " + settings.hostname() + " with " + (tls ? "ESMTPS" : "ESMTP") + (authenticatedUser != null ? "A" : "") + ";\r\n"
+ "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n"; + "\t" + DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()) + "\r\n";
byte[] header = received.getBytes(StandardCharsets.US_ASCII); byte[] header = received.getBytes(StandardCharsets.US_ASCII);
byte[] all = new byte[header.length + message.length]; byte[] all = new byte[header.length + message.length];
@@ -0,0 +1,110 @@
package com.mailserver.smtp;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
/**
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
* <ul>
* <li>{@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;</li>
* <li>otherwise a self-signed certificate for the server hostname, created once in {@code dir} and reused —
* enough for encryption between our own services; other servers deliver over it too (MX delivery does not
* check certificates, RFC 7435 opportunistic TLS).</li>
* </ul>
*/
public final class SmtpTls {
private static final Logger log = LoggerFactory.getLogger(SmtpTls.class);
static final String CERT_FILE = "smtp-cert.pem";
static final String KEY_FILE = "smtp-key.pem";
private SmtpTls() {
}
public static SslContext serverContext(String certFile, String keyFile, Path dir, String hostname) throws Exception {
boolean givenCert = certFile != null && !certFile.isBlank();
boolean givenKey = keyFile != null && !keyFile.isBlank();
if (givenCert != givenKey) {
throw new IllegalStateException("TLS: set both the certificate and the key file, or neither");
}
Path cert;
Path key;
if (givenCert) {
cert = Path.of(certFile);
key = Path.of(keyFile);
log.info("SMTP TLS: certificate {}", cert);
} else {
cert = dir.resolve(CERT_FILE);
key = dir.resolve(KEY_FILE);
if (!Files.exists(cert) || !Files.exists(key)) {
selfSigned(hostname, cert, key);
log.warn("SMTP TLS: created a self-signed certificate for {} in {}", hostname, dir);
} else {
log.info("SMTP TLS: self-signed certificate {}", cert);
}
}
return SslContextBuilder.forServer(cert.toFile(), key.toFile())
.protocols("TLSv1.3", "TLSv1.2")
.build();
}
static void selfSigned(String hostname, Path certFile, Path keyFile) throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, new SecureRandom());
KeyPair pair = generator.generateKeyPair();
X500Name name = new X500Name("CN=" + hostname);
Instant now = Instant.now();
JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name,
new BigInteger(64, new SecureRandom()), Date.from(now.minus(Duration.ofDays(1))),
Date.from(now.plus(Duration.ofDays(3650))), name, pair.getPublic());
builder.addExtension(Extension.subjectAlternativeName, false,
new GeneralNames(new GeneralName(GeneralName.dNSName, hostname)));
X509Certificate certificate = new JcaX509CertificateConverter()
.getCertificate(builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())));
Files.createDirectories(keyFile.toAbsolutePath().getParent());
write(keyFile, pem("PRIVATE KEY", pair.getPrivate().getEncoded()), true);
write(certFile, pem("CERTIFICATE", certificate.getEncoded()), false);
}
private static String pem(String type, byte[] der) {
return "-----BEGIN " + type + "-----\n"
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(der)
+ "\n-----END " + type + "-----\n";
}
private static void write(Path file, String text, boolean secret) throws IOException {
Files.writeString(file, text, StandardCharsets.US_ASCII);
if (secret) {
try {
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
} catch (UnsupportedOperationException ignored) {
// not a POSIX file system
}
}
}
}
@@ -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
@@ -39,6 +41,13 @@ mailserver.smtp.port=${SMTP_PORT:3025}
mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost} mailserver.smtp.hostname=${MAIL_HOSTNAME:localhost}
mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false} mailserver.smtp.auth-without-tls=${SMTP_AUTH_WITHOUT_TLS:false}
mailserver.smtp.max-message-bytes=26214400 mailserver.smtp.max-message-bytes=26214400
# TLS (75.1): STARTTLS on the SMTP port; implicit TLS on SMTPS_PORT (465) if set. Certificate: MAIL_TLS_CERT + MAIL_TLS_KEY
# (PEM, PKCS#8 key), otherwise a self-signed one created once in MAIL_TLS_DIR. MAIL_TLS=false — no TLS
mailserver.tls.enabled=${MAIL_TLS:true}
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
mailserver.tls.key-file=${MAIL_TLS_KEY:}
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
mailserver.smtps.port=${SMTPS_PORT:0}
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
mailserver.outbound.mode=${OUTBOUND_MODE:off} mailserver.outbound.mode=${OUTBOUND_MODE:off}
@@ -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());
}
}
@@ -8,11 +8,18 @@ import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.Socket; import java.net.Socket;
import java.nio.file.Path;
import java.security.cert.X509Certificate;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
@@ -214,15 +221,131 @@ class SmtpServerTest {
assertThat(SmtpServer.path("<a b@c>")).isNull(); assertThat(SmtpServer.path("<a b@c>")).isNull();
} }
// ---------- TLS (75.1) ----------
@TempDir
Path tlsDir;
private SmtpServer tlsServer(boolean implicit) throws Exception {
server.stop();
server = new SmtpServer(new SmtpServer.Settings("127.0.0.1", 0, "mx.example.test", 2000, false,
Duration.ofSeconds(30), SmtpTls.serverContext(null, null, tlsDir, "mx.example.test"), implicit), backend);
server.start();
return server;
}
@Test
void startTlsThenAuthAndTheSessionStartsOver() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
List<String> plain = client.readReplyLines();
assertThat(plain).contains("250-STARTTLS").noneMatch(line -> line.contains("AUTH"));
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("538");
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("250");
assertThat(client.send("STARTTLS")).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
// RFC 3207: MAIL FROM before STARTTLS is forgotten, EHLO again
assertThat(client.send("MAIL FROM:<x@elsewhere.test>")).startsWith("503");
client.send("EHLO laptop");
List<String> secure = client.readReplyLines();
assertThat(secure).contains("250-AUTH PLAIN LOGIN").noneMatch(line -> line.contains("STARTTLS"));
assertThat(client.send("STARTTLS")).startsWith("503");
assertThat(client.send("AUTH PLAIN " + b64("\0alice@example.test\0secret"))).startsWith("235");
client.send("MAIL FROM:<alice@example.test>");
client.send("RCPT TO:<friend@gmail.com>");
client.send("DATA");
assertThat(client.send("Subject: tls\r\n\r\nhi\r\n.")).startsWith("250");
}
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
// CVE-2011-0411: commands sent in plain text right after STARTTLS must not run inside the TLS session
@Test
void plainTextInjectedAfterStartTlsIsNotExecuted() throws Exception {
tlsServer(false);
try (Client client = new Client(server.port())) {
client.read();
client.send("EHLO laptop");
client.readReplyLines();
client.write("STARTTLS\r\nMAIL FROM:<x@elsewhere.test>\r\nRCPT TO:<bob@example.test>\r\n");
assertThat(client.read()).isEqualTo("220 2.0.0 Ready to start TLS");
client.startTls();
String reply = client.read();
assertThat(reply == null || reply.startsWith("421")).as(reply).isTrue();
}
assertThat(backend.envelopes).isEmpty();
}
@Test
void realClientRequiringStartTlsDelivers() throws Exception {
tlsServer(false);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.required", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over STARTTLS");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.envelopes).hasSize(1);
assertThat(backend.envelopes.get(0).authenticatedUser()).isEqualTo("alice@example.test");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void implicitTlsPortWorksWithoutStartTls() throws Exception {
tlsServer(true);
Properties props = new Properties();
props.put("mail.smtp.host", "127.0.0.1");
props.put("mail.smtp.port", String.valueOf(server.port()));
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.ssl.trust", "*");
props.put("mail.smtp.ssl.checkserveridentity", "false"); // self-signed for mx.example.test, connecting to 127.0.0.1
props.put("mail.smtp.auth", "true");
Session session = Session.getInstance(props);
MimeMessage message = new MimeMessage(session);
message.setFrom(new InternetAddress("alice@example.test"));
message.setRecipient(Message.RecipientType.TO, new InternetAddress("bob@example.test"));
message.setSubject("Over 465");
message.setText("hello");
Transport.send(message, "alice@example.test", "secret");
assertThat(backend.messages.get(0)).contains("with ESMTPSA;");
}
@Test
void selfSignedCertificateIsCreatedOnceAndReused() throws Exception {
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
String first = java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE));
SmtpTls.serverContext(null, null, tlsDir, "mx.example.test");
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.CERT_FILE))).isEqualTo(first);
assertThat(java.nio.file.Files.readString(tlsDir.resolve(SmtpTls.KEY_FILE))).startsWith("-----BEGIN PRIVATE KEY-----");
}
private static String b64(String text) { private static String b64(String text) {
return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(text.getBytes(StandardCharsets.UTF_8));
} }
/** Line-oriented test client. */ /** Line-oriented test client. */
static final class Client implements AutoCloseable { static final class Client implements AutoCloseable {
private final Socket socket; private Socket socket;
private final BufferedReader in; private BufferedReader in;
private final OutputStream out; private OutputStream out;
Client(int port) throws Exception { Client(int port) throws Exception {
socket = new Socket("127.0.0.1", port); socket = new Socket("127.0.0.1", port);
@@ -231,6 +354,28 @@ class SmtpServerTest {
out = socket.getOutputStream(); out = socket.getOutputStream();
} }
/** Upgrade the connection to TLS (any certificate is trusted — the test server's is self-signed). */
void startTls() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new X509TrustManager() {
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
}}, null);
SSLSocket ssl = (SSLSocket) context.getSocketFactory().createSocket(socket, "127.0.0.1", socket.getPort(), true);
ssl.setUseClientMode(true);
ssl.startHandshake();
socket = ssl;
in = new BufferedReader(new InputStreamReader(ssl.getInputStream(), StandardCharsets.UTF_8));
out = ssl.getOutputStream();
}
void write(String text) throws Exception { void write(String text) throws Exception {
out.write(text.getBytes(StandardCharsets.UTF_8)); out.write(text.getBytes(StandardCharsets.UTF_8));
out.flush(); out.flush();
+4 -2
View File
@@ -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