diff --git a/README.md b/README.md
index acb570c..b7c1335 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
```
- **Приём (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`.
@@ -59,6 +60,10 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
| `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` |
| `OUTBOUND_MODE` | `off` / `mx` / `smarthost` | `off` |
| `OUTBOUND_HOST`, `OUTBOUND_PORT`, `OUTBOUND_USERNAME`, `OUTBOUND_PASSWORD`, `OUTBOUND_TLS` | smarthost (`starttls` / `ssl` / `none`) | —, `587`, —, —, `starttls` |
@@ -97,7 +102,8 @@ cd frontend && npm install && npx ng serve # http://loca
## Не сделано
-- [ ] TLS: STARTTLS для SMTP и IMAP. Пока `AUTH` — только в закрытой сети.
+- [ ] TLS для IMAP (вместе с переписыванием IMAP). SMTP — сделано: `STARTTLS` и порт 465.
+- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
- [ ] IMAP выключен: упрощённый, блокирует сетевой поток (`.block()`). Переписать и проверить с Thunderbird.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 222bda2..b44c927 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -24,11 +24,11 @@ RUN chmod +x gradlew && ./gradlew :backend:bootJar --no-daemon -x test
FROM eclipse-temurin:21-jre
# 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 \
- && 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
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 MAIL_PASSWORDS_FILE=/app/data/initial-passwords.txt
+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
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/MailServerConfig.java b/backend/src/main/java/com/mailserver/config/MailServerConfig.java
index f708a37..70ad73c 100644
--- a/backend/src/main/java/com/mailserver/config/MailServerConfig.java
+++ b/backend/src/main/java/com/mailserver/config/MailServerConfig.java
@@ -3,12 +3,16 @@ package com.mailserver.config;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.smtp.MailRouter;
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.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
+import java.nio.file.Path;
import java.time.Duration;
import java.util.Locale;
@@ -17,16 +21,46 @@ import java.util.Locale;
@EnableScheduling
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")
@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.port:3025}") int port,
@Value("${mailserver.smtp.hostname:localhost}") String hostname,
@Value("${mailserver.smtp.max-message-bytes:26214400}") long maxBytes,
@Value("${mailserver.smtp.auth-without-tls:false}") boolean authWithoutTls,
@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);
}
/**
diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java
index 987fe99..7c18a13 100644
--- a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java
+++ b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java
@@ -1,13 +1,22 @@
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.MessageToMessageDecoder;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslHandler;
import io.netty.handler.timeout.ReadTimeoutHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
+import reactor.netty.Connection;
import reactor.netty.DisposableServer;
+import reactor.netty.NettyOutbound;
import reactor.netty.tcp.TcpServer;
import java.io.ByteArrayOutputStream;
@@ -32,7 +41,10 @@ import java.util.concurrent.TimeUnit;
*
the message is stored or queued before the client gets 250 — on failure 451 and the client retries;
*
database and DNS work runs on a worker thread, not on the event loop.
*
- * 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 {
@@ -42,9 +54,32 @@ public class SmtpServer {
static final int MAX_LINE = 64 * 1024;
static final int MAX_RECIPIENTS = 100;
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,
- 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 {
+ @Override
+ protected void decode(ChannelHandlerContext ctx, ByteBuf frame, List