diff --git a/README.md b/README.md
index 70c6112..3436c47 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +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` и потом переиспользуется.
+- **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` и потом переиспользуется. Свой сертификат сервер перечитывает сам, когда файлы меняются (проверка раз в минуту): обновлённый Let's Encrypt подхватывается без перезапуска, недописанный файл не ломает TLS — остаётся прежний сертификат.
- **Не открытый релей.**
- Письмо на свой домен принимается от кого угодно.
- Письмо на чужой домен — только после `AUTH`, иначе `550 5.7.1 Relaying denied`.
@@ -72,7 +72,7 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `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_CERT`, `MAIL_TLS_KEY` | свой сертификат: PEM-цепочка и ключ (PKCS#8, `RSA PRIVATE KEY` или `EC PRIVATE KEY`); перечитывается при изменении; без них — самоподписанный | — |
| `MAIL_TLS_DIR` | где лежит самоподписанный сертификат | `./tls` (в образе `/app/data/tls`) |
| `SMTPS_PORT` | порт неявного TLS (465); `0` — выключен | `0` |
| `IMAP_ENABLED`, `IMAP_PORT`, `IMAPS_PORT` | IMAP: включён; порт со STARTTLS; порт неявного TLS (`0` — выключен) | `true`, `3143`, `0` |
@@ -130,15 +130,43 @@ gitea.jambotron.com/jambotron_gang/reactive-mail-server:<первые 8 симв
docker compose -f docker-compose.image.yml up -d
```
+### Настоящая почта в интернете (75.6)
+
+`docker-compose.public.yml` — приём писем для `MAIL_DOMAINS` и отправка наружу прямо на MX получателей (`OUTBOUND_MODE=mx`, порт 25), с подписью DKIM; регистрация закрыта, ловушки нет. Нужна своя ВМ с публичным адресом (Jambotron 60.8: «Публичный адрес», SMTP «да» — порт 25 по умолчанию закрыт). На этом адресе открыты:
+
+| Порт | Что |
+|---|---|
+| 25 | SMTP: письма от других серверов (STARTTLS) |
+| 587 | submission для почтовых программ (STARTTLS + вход) — тот же SMTP, что 25 |
+| 465 | SMTPS, неявный TLS |
+| 993 | IMAPS, неявный TLS |
+| 80 | только Let's Encrypt (HTTP-01), сервис `acme` |
+
+Сертификат для `MAIL_HOSTNAME` выпускает и продлевает сервис `acme` (lego, ключ EC256, продление за 30 дней, проверка дважды в сутки); хук кладёт пару в том `tls`, сервер перечитывает её сам. `mail` стартует после первого сертификата. Веб-почта (сервис `mail`, порт `8080`) — через Caddy Jambotron под **другим** именем, например `webmail.<домен>`: `MAIL_HOSTNAME` смотрит на публичный адрес ВМ, а не на Caddy.
+
+Переменные: `MAIL_DOMAINS` (например `jambocheck.com`), `MAIL_HOSTNAME` (`mail.jambocheck.com`), `ACME_EMAIL` (по умолчанию `postmaster@<домен>`).
+
+DNS до первого запуска:
+
+| Запись | Значение |
+|---|---|
+| `A mail.<домен>` | публичный адрес ВМ |
+| PTR публичного адреса | `mail.<домен>` (панель хостера, Reverse DNS) |
+| `MX <домен>` | `10 mail.<домен>` |
+| `TXT <домен>` | `v=spf1 ip4:<публичный адрес> -all` |
+| `TXT _dmarc.<домен>` | `v=DMARC1; p=none; rua=mailto:postmaster@<домен>` |
+| `TXT mail._domainkey.<домен>` | из лога `mail` при первом запуске (ключ DKIM лежит в томе `data`) |
+
+Ящик `admin@<домен>` — пароль в логе `mail` при первом запуске и в `/app/data/initial-passwords.txt`.
+
## Не сделано
-- [ ] Сертификат Let's Encrypt для настоящей почты (75.6): сейчас — свой файл или самоподписанный.
- [ ] IMAP: проверить с Thunderbird на настоящем адресе; вложенные папки, CONDSTORE/QRESYNC, поиск по `BODY`/`TEXT` без перебора писем.
- [ ] Пересылка не берёт вложения исходного письма — их надо прикрепить заново.
- [ ] Встроенные картинки HTML-писем (`cid:`) показываются как вложения, а не внутри письма.
- [ ] Защита регистрации и входа от перебора (лимит попыток, капча).
- [ ] Письмо нескольким получателям: при ошибке на втором первый может получить его дважды (после повтора отправителя).
-- [ ] Своя настоящая почта: отдельная VPS, порт 25, PTR, отдельный домен (чек-лист Jambotron 75.6).
+- [ ] Настоящая почта (75.6): `docker-compose.public.yml` готов, первый запуск — `mail.jambocheck.com` на публичном адресе Horizon; адрес «прогревать» несколько недель, письма пользователям Jambotron пока через Resend (75.5).
## Лицензия
diff --git a/backend/src/main/java/com/mailserver/smtp/ReloadingKeyManager.java b/backend/src/main/java/com/mailserver/smtp/ReloadingKeyManager.java
new file mode 100644
index 0000000..ddb182f
--- /dev/null
+++ b/backend/src/main/java/com/mailserver/smtp/ReloadingKeyManager.java
@@ -0,0 +1,194 @@
+package com.mailserver.smtp;
+
+import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
+import org.bouncycastle.openssl.PEMKeyPair;
+import org.bouncycastle.openssl.PEMParser;
+import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.X509ExtendedKeyManager;
+import java.io.InputStream;
+import java.io.Reader;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.security.Principal;
+import java.security.PrivateKey;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Server certificate and key from PEM files, read again when the files change — a Let's Encrypt certificate
+ * renewed on disk (lego, certbot) is picked up without a restart, for STARTTLS and implicit TLS alike: the JDK
+ * asks the key manager on every handshake. The files are checked at most once per {@code interval}; a file
+ * that cannot be read (a renewal half written) keeps the previous certificate and is tried again next time.
+ *
+ * Key: PKCS#8 ({@code PRIVATE KEY}) or the traditional {@code RSA PRIVATE KEY} / {@code EC PRIVATE KEY} that
+ * lego and certbot write. Each loaded pair gets its own alias, so a handshake that asks for the chain and then
+ * for the key never mixes two generations.
+ */
+public final class ReloadingKeyManager extends X509ExtendedKeyManager {
+
+ private static final Logger log = LoggerFactory.getLogger(ReloadingKeyManager.class);
+ private static final String ALIAS_PREFIX = "mail-";
+
+ record Material(String alias, X509Certificate[] chain, PrivateKey key, FileTime certTime, FileTime keyTime) {
+ }
+
+ private final Path certFile;
+ private final Path keyFile;
+ private final Duration interval;
+ private final Clock clock;
+ /** The current pair and the one before it (a handshake may still hold its alias) */
+ private final Map byAlias = new ConcurrentHashMap<>();
+ private volatile Material current;
+ private volatile long checkedAt;
+ private int generation;
+
+ public ReloadingKeyManager(Path certFile, Path keyFile, Duration interval, Clock clock) throws Exception {
+ this.certFile = certFile;
+ this.keyFile = keyFile;
+ this.interval = interval;
+ this.clock = clock;
+ install(load());
+ this.checkedAt = clock.millis();
+ }
+
+ /** The pair to use now; reads the files again when the interval has passed and they changed */
+ Material material() {
+ long now = clock.millis();
+ if (now - checkedAt >= interval.toMillis()) {
+ synchronized (this) {
+ if (now - checkedAt >= interval.toMillis()) {
+ checkedAt = now;
+ reloadIfChanged();
+ }
+ }
+ }
+ return current;
+ }
+
+ private void reloadIfChanged() {
+ Material old = current;
+ try {
+ if (Files.getLastModifiedTime(certFile).equals(old.certTime())
+ && Files.getLastModifiedTime(keyFile).equals(old.keyTime())) {
+ return;
+ }
+ Material fresh = load();
+ install(fresh);
+ log.info("TLS: certificate {} reloaded, valid until {}", certFile, fresh.chain()[0].getNotAfter());
+ } catch (Exception e) {
+ log.warn("TLS: cannot read {} / {} ({}), keeping the certificate valid until {}",
+ certFile, keyFile, e.toString(), old.chain()[0].getNotAfter());
+ }
+ }
+
+ private synchronized void install(Material material) {
+ Material previous = current;
+ byAlias.keySet().removeIf(alias -> previous == null || !alias.equals(previous.alias()));
+ byAlias.put(material.alias(), material);
+ current = material;
+ }
+
+ private Material load() throws Exception {
+ FileTime certTime = Files.getLastModifiedTime(certFile);
+ FileTime keyTime = Files.getLastModifiedTime(keyFile);
+ X509Certificate[] chain;
+ try (InputStream in = Files.newInputStream(certFile)) {
+ chain = CertificateFactory.getInstance("X.509").generateCertificates(in).stream()
+ .map(X509Certificate.class::cast).toArray(X509Certificate[]::new);
+ }
+ if (chain.length == 0) {
+ throw new IllegalStateException("no certificate in " + certFile);
+ }
+ PrivateKey key = readKey(keyFile);
+ if (!chain[0].getPublicKey().getAlgorithm().equals(key.getAlgorithm())) {
+ throw new IllegalStateException("the key in " + keyFile + " is " + key.getAlgorithm()
+ + ", the certificate is " + chain[0].getPublicKey().getAlgorithm());
+ }
+ String alias;
+ synchronized (this) {
+ alias = ALIAS_PREFIX + (++generation);
+ }
+ return new Material(alias, chain, key, certTime, keyTime);
+ }
+
+ static PrivateKey readKey(Path file) throws Exception {
+ try (Reader reader = Files.newBufferedReader(file, StandardCharsets.US_ASCII);
+ PEMParser parser = new PEMParser(reader)) {
+ Object object;
+ while ((object = parser.readObject()) != null) {
+ JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
+ if (object instanceof PrivateKeyInfo info) {
+ return converter.getPrivateKey(info);
+ }
+ if (object instanceof PEMKeyPair pair) {
+ return converter.getPrivateKey(pair.getPrivateKeyInfo()); // SEC1 may come without the public part
+ }
+ // EC PARAMETERS before EC PRIVATE KEY (openssl ecparam -genkey) — skip
+ }
+ }
+ throw new IllegalStateException("no private key in " + file);
+ }
+
+ private String serverAlias(String keyType) {
+ Material material = material();
+ String algorithm = material.key().getAlgorithm();
+ boolean fits = keyType == null || keyType.equalsIgnoreCase(algorithm)
+ || ("RSA".equals(algorithm) && keyType.equalsIgnoreCase("RSASSA-PSS"))
+ || ("EC".equals(algorithm) && keyType.toUpperCase().startsWith("EC"));
+ return fits ? material.alias() : null;
+ }
+
+ @Override
+ public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
+ return serverAlias(keyType);
+ }
+
+ @Override
+ public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
+ return serverAlias(keyType);
+ }
+
+ @Override
+ public String[] getServerAliases(String keyType, Principal[] issuers) {
+ String alias = serverAlias(keyType);
+ return alias == null ? null : new String[]{alias};
+ }
+
+ @Override
+ public X509Certificate[] getCertificateChain(String alias) {
+ Material material = alias == null ? null : byAlias.get(alias);
+ return material == null ? null : material.chain().clone();
+ }
+
+ @Override
+ public PrivateKey getPrivateKey(String alias) {
+ Material material = alias == null ? null : byAlias.get(alias);
+ return material == null ? null : material.key();
+ }
+
+ @Override
+ public String[] getClientAliases(String keyType, Principal[] issuers) {
+ return null;
+ }
+
+ @Override
+ public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
+ return null;
+ }
+
+ @Override
+ public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) {
+ return null;
+ }
+}
diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpTls.java b/backend/src/main/java/com/mailserver/smtp/SmtpTls.java
index 879eb42..a0492a5 100644
--- a/backend/src/main/java/com/mailserver/smtp/SmtpTls.java
+++ b/backend/src/main/java/com/mailserver/smtp/SmtpTls.java
@@ -22,6 +22,7 @@ import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
+import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
@@ -30,7 +31,8 @@ import java.util.Date;
/**
* Certificate for SMTP TLS (STARTTLS and implicit TLS):
*
- * - {@code cert} and {@code key} given (PEM: certificate chain and PKCS#8 key, e.g. from Let's Encrypt) — used as is;
+ * - {@code cert} and {@code key} given (PEM: certificate chain and the key — PKCS#8, RSA or EC, e.g. from Let's Encrypt) —
+ * used as is and read again when the files change ({@link ReloadingKeyManager}, checked once a minute);
* - 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).
@@ -41,6 +43,7 @@ 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";
+ static final Duration RELOAD_CHECK = Duration.ofMinutes(1);
private SmtpTls() {
}
@@ -67,7 +70,8 @@ public final class SmtpTls {
log.info("SMTP TLS: self-signed certificate {}", cert);
}
}
- return SslContextBuilder.forServer(cert.toFile(), key.toFile())
+ // Read again when the files change (a renewed Let's Encrypt certificate) — no restart needed
+ return SslContextBuilder.forServer(new ReloadingKeyManager(cert, key, RELOAD_CHECK, Clock.systemUTC()))
.protocols("TLSv1.3", "TLSv1.2")
.build();
}
diff --git a/backend/src/test/java/com/mailserver/smtp/ReloadingKeyManagerTest.java b/backend/src/test/java/com/mailserver/smtp/ReloadingKeyManagerTest.java
new file mode 100644
index 0000000..8ca33cb
--- /dev/null
+++ b/backend/src/test/java/com/mailserver/smtp/ReloadingKeyManagerTest.java
@@ -0,0 +1,149 @@
+package com.mailserver.smtp;
+
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.StringWriter;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ReloadingKeyManagerTest {
+
+ /** A clock moved by hand */
+ static final class TestClock extends Clock {
+ Instant now = Instant.parse("2026-11-06T12:00:00Z");
+
+ @Override
+ public ZoneOffset getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(java.time.ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return now;
+ }
+ }
+
+ @Test
+ void readsTheFilesAgainWhenTheyChange(@TempDir Path dir) throws Exception {
+ Path cert = dir.resolve("cert.pem");
+ Path key = dir.resolve("key.pem");
+ SmtpTls.selfSigned("old.example", cert, key);
+ TestClock clock = new TestClock();
+ ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
+
+ String first = manager.chooseEngineServerAlias("RSA", null, null);
+ assertEquals("CN=old.example", subject(manager.getCertificateChain(first)));
+ assertNull(manager.chooseEngineServerAlias("EC", null, null), "an RSA key is not offered for EC");
+
+ SmtpTls.selfSigned("new.example", cert, key);
+ Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
+ Files.setLastModifiedTime(key, FileTime.from(Instant.now().plusSeconds(5)));
+
+ assertEquals(first, manager.chooseEngineServerAlias("RSA", null, null), "not checked before the interval");
+
+ clock.now = clock.now.plus(Duration.ofMinutes(1));
+ String second = manager.chooseEngineServerAlias("RSA", null, null);
+ assertNotEquals(first, second);
+ assertEquals("CN=new.example", subject(manager.getCertificateChain(second)));
+ assertNotNull(manager.getPrivateKey(second));
+ assertNotNull(manager.getCertificateChain(first), "a handshake that holds the previous alias still finishes");
+ }
+
+ @Test
+ void keepsTheOldCertificateWhenTheNewFileIsBroken(@TempDir Path dir) throws Exception {
+ Path cert = dir.resolve("cert.pem");
+ Path key = dir.resolve("key.pem");
+ SmtpTls.selfSigned("old.example", cert, key);
+ TestClock clock = new TestClock();
+ ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), clock);
+
+ Files.writeString(cert, "-----BEGIN CERTIFICATE-----\nhalf");
+ Files.setLastModifiedTime(cert, FileTime.from(Instant.now().plusSeconds(5)));
+ clock.now = clock.now.plus(Duration.ofMinutes(2));
+
+ String alias = manager.chooseEngineServerAlias("RSA", null, null);
+ assertEquals("CN=old.example", subject(manager.getCertificateChain(alias)));
+ }
+
+ @Test
+ void readsTraditionalEcKeysAsLegoWritesThem(@TempDir Path dir) throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(256);
+ KeyPair pair = generator.generateKeyPair();
+ X500Name name = new X500Name("CN=mail.example");
+ X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(
+ new JcaX509v3CertificateBuilder(name, BigInteger.ONE, new Date(System.currentTimeMillis() - 86_400_000L),
+ new Date(System.currentTimeMillis() + 86_400_000L), name, pair.getPublic())
+ .build(new JcaContentSignerBuilder("SHA256withECDSA").build(pair.getPrivate())));
+ Path cert = dir.resolve("cert.pem");
+ Path key = dir.resolve("key.pem");
+ Files.writeString(cert, pem(certificate));
+ Files.writeString(key, sec1(pair)); // -----BEGIN EC PRIVATE KEY----- (SEC1 with the curve), not PKCS#8
+ assertTrue(Files.readString(key).contains("EC PRIVATE KEY"));
+
+ ReloadingKeyManager manager = new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock());
+ String alias = manager.chooseEngineServerAlias("EC", null, null);
+ assertNotNull(alias);
+ assertEquals("EC", manager.getPrivateKey(alias).getAlgorithm());
+ assertNull(manager.chooseEngineServerAlias("RSA", null, null));
+ }
+
+ @Test
+ void refusesAKeyThatDoesNotMatchTheCertificateType(@TempDir Path dir) throws Exception {
+ Path cert = dir.resolve("cert.pem");
+ Path key = dir.resolve("key.pem");
+ SmtpTls.selfSigned("rsa.example", cert, key);
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(256);
+ Files.writeString(key, sec1(generator.generateKeyPair()));
+ assertThrows(IllegalStateException.class,
+ () -> new ReloadingKeyManager(cert, key, Duration.ofMinutes(1), new TestClock()));
+ }
+
+ /** As lego and openssl ecparam -genkey write it: SEC1 with the named curve secp256r1 */
+ private static String sec1(KeyPair pair) throws Exception {
+ java.security.interfaces.ECPrivateKey key = (java.security.interfaces.ECPrivateKey) pair.getPrivate();
+ org.bouncycastle.asn1.sec.ECPrivateKey sec1 = new org.bouncycastle.asn1.sec.ECPrivateKey(256, key.getS(),
+ new org.bouncycastle.asn1.x9.X962Parameters(org.bouncycastle.asn1.sec.SECObjectIdentifiers.secp256r1));
+ StringWriter out = new StringWriter();
+ try (org.bouncycastle.util.io.pem.PemWriter writer = new org.bouncycastle.util.io.pem.PemWriter(out)) {
+ writer.writeObject(new org.bouncycastle.util.io.pem.PemObject("EC PRIVATE KEY", sec1.getEncoded()));
+ }
+ return out.toString();
+ }
+
+ private static String subject(X509Certificate[] chain) {
+ return chain[0].getSubjectX500Principal().getName();
+ }
+
+ private static String pem(Object object) throws Exception {
+ StringWriter out = new StringWriter();
+ try (JcaPEMWriter writer = new JcaPEMWriter(out)) {
+ writer.writeObject(object);
+ }
+ return out.toString();
+ }
+}
diff --git a/docker-compose.public.yml b/docker-compose.public.yml
new file mode 100644
index 0000000..9a2c6c1
--- /dev/null
+++ b/docker-compose.public.yml
@@ -0,0 +1,154 @@
+# Real mail on the internet (v0.2.3 75.6): receives mail for MAIL_DOMAINS and sends it straight to the recipients'
+# MX servers (port 25), DKIM-signed. From the ready image, like docker-compose.image.yml.
+#
+# Needs a VM of its own with a public address (Jambotron 60.8: «Публичный адрес» of the VM, SMTP «да» — port 25
+# is closed by default) — the ports below are published on that address:
+# 25 SMTP: mail from other servers (STARTTLS) and from mail programs
+# 587 submission: mail programs (STARTTLS + login) — the same SMTP listener as 25
+# 465 SMTPS: mail programs, implicit TLS
+# 993 IMAPS: mail programs, implicit TLS
+# 80 only for Let's Encrypt (HTTP-01) — service "acme"
+# The web client + API (service "mail", port 8080) goes through Jambotron's Caddy under ANOTHER name
+# (webmail.): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
+#
+# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
+# A mail. → public address of the VM
+# PTR public address → mail. (hoster's panel: Reverse DNS)
+# MX → 10 mail.
+# TXT → "v=spf1 ip4: -all"
+# TXT _dmarc. → "v=DMARC1; p=none; rua=mailto:postmaster@"
+# TXT mail._domainkey. → printed in the "mail" log on the first start (DKIM key, kept in the data volume)
+#
+# Mailbox admin@: its password is in the "mail" log on the first start and in /app/data/initial-passwords.txt.
+# Registration is closed: new mailboxes only by the admin. No catch-all.
+name: reactive-mail-public
+
+services:
+ secrets-init:
+ image: alpine:3.20
+ restart: "no"
+ command:
+ - sh
+ - -c
+ - |
+ umask 077
+ for name in DB_PASSWORD JWT_SECRET; do
+ [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
+ done
+ chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
+ volumes:
+ - secrets:/secrets
+
+ # Let's Encrypt for MAIL_HOSTNAME: issued on the first start, renewed 30 days before the end (checked twice a day).
+ # The deploy hook copies the pair to /tls (readable by the "mail" user 10001); the server reads it again within
+ # a minute — no restart
+ acme:
+ image: goacme/lego:v5.5.2
+ restart: unless-stopped
+ ports:
+ - "80:80"
+ entrypoint:
+ - sh
+ - -c
+ - |
+ cat > /usr/local/bin/jb-deploy <<'EOF'
+ #!/bin/sh
+ set -e
+ cp "$$LEGO_HOOK_CERT_PATH" /tls/cert.pem.new
+ cp "$$LEGO_HOOK_CERT_KEY_PATH" /tls/key.pem.new
+ chown 10001 /tls/cert.pem.new /tls/key.pem.new
+ chmod 600 /tls/key.pem.new
+ chmod 644 /tls/cert.pem.new
+ mv /tls/key.pem.new /tls/key.pem
+ mv /tls/cert.pem.new /tls/cert.pem
+ echo "certificate for $$LEGO_HOOK_CERT_DOMAINS copied to /tls"
+ EOF
+ chmod 755 /usr/local/bin/jb-deploy
+ while true; do
+ /lego run --accept-tos --email "$$ACME_EMAIL" --domains "$$MAIL_HOSTNAME" --key-type EC256 \
+ --http --http.address :80 --path /var/lib/lego --renew-days 30 --no-random-sleep \
+ --deploy-hook /usr/local/bin/jb-deploy || echo "lego failed — next try in 1 hour"
+ [ -s /tls/cert.pem ] && sleep 43200 || sleep 3600
+ done
+ environment:
+ MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com}
+ ACME_EMAIL: ${ACME_EMAIL:-postmaster@${MAIL_DOMAINS}}
+ LEGO_LOG_FORMAT: text
+ volumes:
+ - lego:/var/lib/lego
+ - tls:/tls
+ healthcheck:
+ test: ["CMD-SHELL", "test -s /tls/cert.pem && test -s /tls/key.pem"]
+ interval: 15s
+ timeout: 5s
+ start_period: 10m
+ retries: 3
+ mem_limit: 128m
+
+ db:
+ image: postgres:17
+ restart: unless-stopped
+ depends_on:
+ secrets-init:
+ condition: service_completed_successfully
+ environment:
+ POSTGRES_DB: mailserver
+ POSTGRES_USER: mail
+ POSTGRES_PASSWORD_FILE: /run/mail-secrets/DB_PASSWORD
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ - secrets:/run/mail-secrets:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U mail -d mailserver"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ mem_limit: 256m
+
+ mail:
+ image: gitea.jambotron.com/jambotron_gang/reactive-mail-server:${MAIL_IMAGE_TAG:-latest}
+ pull_policy: always
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ acme:
+ condition: service_healthy # waits for the first certificate
+ ports:
+ - "25:3025"
+ - "587:3025"
+ - "465:3465"
+ - "993:3993"
+ expose:
+ - "8080" # web client + API → webmail. in Jambotron
+ environment:
+ DB_HOST: db
+ MAILSERVER_SECRETS_DIR: /run/mail-secrets
+ MAIL_DOMAINS: ${MAIL_DOMAINS:?MAIL_DOMAINS, e.g. jambocheck.com}
+ MAIL_HOSTNAME: ${MAIL_HOSTNAME:?MAIL_HOSTNAME, e.g. mail.jambocheck.com}
+ MAIL_TLS_CERT: /tls/cert.pem
+ MAIL_TLS_KEY: /tls/key.pem
+ SMTPS_PORT: "3465"
+ IMAPS_PORT: "3993"
+ DKIM_DOMAIN: ${MAIL_DOMAINS}
+ DKIM_SELECTOR: mail
+ DKIM_KEYS_DIR: /app/data/dkim-keys
+ MAIL_CATCH_ALL: ""
+ MAIL_REGISTRATION: "false"
+ OUTBOUND_MODE: mx
+ SMTP_AUTH_WITHOUT_TLS: "false"
+ IMAP_AUTH_WITHOUT_TLS: "false"
+ SPF_ENABLED: "true"
+ DMARC_ENABLED: "true"
+ volumes:
+ - data:/app/data
+ - secrets:/run/mail-secrets:ro
+ - tls:/tls:ro
+ mem_limit: 512m
+
+volumes:
+ secrets:
+ pgdata:
+ data:
+ lego:
+ tls: