Безопасность, SMTP, очередь отправки, DKIM, деплой на Jambotron (Jambotron v0.2.3 75.1–75.3)
- SMTP-сервер переписан: разбор по строкам, AUTH PLAIN/LOGIN, нет открытого релея, MAIL FROM только свой после входа, 250 только после сохранения (иначе 451), лимит размера - отправка наружу: очередь outbound_queue (V2) с повторами ~22 ч и уведомлением о недоставке; Jakarta Mail, режимы off / mx / smarthost, байты письма не меняются (DKIM) - DKIM: подпись utils-mail-dkim, проверка Apache jDKIM; ключ PKCS#8, старый PKCS#1 читается - права: письма, папки, вложения — только владельцу (было: любой по номеру); имена вложений без ../ - секреты без значений по умолчанию (DB_PASSWORD, JWT_SECRET), регистрация закрыта, admin123 и /api/simulate/receive — только профиль dev - веб-клиент внутри jar (относительный /api), Dockerfile в 3 этапа, не root, health; docker-compose.jambotron.yml: секреты создаются при первом запуске, наружу только 8080 - gradle-wrapper.jar был в .gitignore — сборка не запускалась - тесты: 26 (SMTP реальным клиентом и сокетом, GreenMail, DKIM подпись+проверка, очередь) α Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,458 +1,209 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMWriter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.apache.james.jdkim.DKIMVerifier;
|
||||
import org.apache.james.jdkim.api.PublicKeyRecordRetriever;
|
||||
import org.apache.james.jdkim.api.SignatureRecord;
|
||||
import org.apache.james.jdkim.exceptions.FailException;
|
||||
import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever;
|
||||
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||
import org.bouncycastle.openssl.PEMKeyPair;
|
||||
import org.bouncycastle.openssl.PEMParser;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
|
||||
import org.simplejavamail.utils.mail.dkim.Canonicalization;
|
||||
import org.simplejavamail.utils.mail.dkim.DkimMessage;
|
||||
import org.simplejavamail.utils.mail.dkim.DkimSigner;
|
||||
import org.simplejavamail.utils.mail.dkim.SigningAlgorithm;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.StringReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.*;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.nio.file.attribute.PosixFilePermissions;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateCrtKey;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем.
|
||||
*
|
||||
* DKIM добавляет криптографическую подпись к каждому исходящему письму,
|
||||
* чтобы получатель мог проверить, что письмо действительно отправлено
|
||||
* владельцем домена и не было изменено в пути.
|
||||
*
|
||||
* При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл.
|
||||
* Подписывает заголовки From, Subject, Date, To и тело сообщения.
|
||||
* DKIM (RFC 6376): signs our outgoing mail and verifies incoming mail.
|
||||
* Signing — simple-java-mail utils-mail-dkim (rsa-sha256, relaxed/relaxed); verification — Apache James jDKIM
|
||||
* with the sender's public key from DNS. The key is generated once and kept in {@code keys-dir/dkim-private.pem};
|
||||
* the DNS record to publish is printed to the log at start and available from {@link #dnsRecord()}.
|
||||
*/
|
||||
@Service
|
||||
public class DkimService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
|
||||
|
||||
private static final String DKIM_SELECTOR = "default";
|
||||
/** Result of checking an incoming message. */
|
||||
public record Verification(boolean signed, boolean passed, String domain) {
|
||||
static final Verification NONE = new Verification(false, false, null);
|
||||
}
|
||||
|
||||
@Value("${mailserver.dkim.domain:localhost}")
|
||||
private String domain;
|
||||
private final String domain;
|
||||
private final String selector;
|
||||
private final Path keysDir;
|
||||
private final boolean enabled;
|
||||
private final DKIMVerifier verifier;
|
||||
|
||||
@Value("${mailserver.dkim.keys-dir:./dkim-keys}")
|
||||
private String keysDir;
|
||||
|
||||
@Value("${mailserver.dkim.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
private PrivateKey privateKey;
|
||||
private PublicKey publicKey;
|
||||
private RSAPrivateKey privateKey;
|
||||
private String publicKeyBase64;
|
||||
|
||||
@Autowired
|
||||
public DkimService(@Value("${mailserver.dkim.domain:localhost}") String domain,
|
||||
@Value("${mailserver.dkim.selector:mail}") String selector,
|
||||
@Value("${mailserver.dkim.keys-dir:./dkim-keys}") String keysDir,
|
||||
@Value("${mailserver.dkim.enabled:true}") boolean enabled) {
|
||||
this(domain, selector, Paths.get(keysDir), enabled, new DNSPublicKeyRecordRetriever());
|
||||
}
|
||||
|
||||
// Tests pass their own public key lookup instead of DNS
|
||||
DkimService(String domain, String selector, Path keysDir, boolean enabled, PublicKeyRecordRetriever keys) {
|
||||
this.domain = domain.toLowerCase(Locale.ROOT);
|
||||
this.selector = selector;
|
||||
this.keysDir = keysDir;
|
||||
this.enabled = enabled;
|
||||
this.verifier = new DKIMVerifier(keys);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
|
||||
if (!enabled) {
|
||||
log.info("DKIM подписи отключены");
|
||||
log.info("DKIM signing disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Path keyDir = Paths.get(keysDir);
|
||||
Files.createDirectories(keyDir);
|
||||
|
||||
Path privateKeyFile = keyDir.resolve("dkim-private.pem");
|
||||
Path publicKeyFile = keyDir.resolve("dkim-public.pem");
|
||||
|
||||
if (Files.exists(privateKeyFile) && Files.exists(publicKeyFile)) {
|
||||
// Загружаем существующие ключи
|
||||
loadKeys(privateKeyFile, publicKeyFile);
|
||||
log.info("DKIM: загружены существующие ключи для домена {}", domain);
|
||||
Files.createDirectories(keysDir);
|
||||
Path keyFile = keysDir.resolve("dkim-private.pem");
|
||||
if (Files.exists(keyFile)) {
|
||||
privateKey = readPrivateKey(Files.readString(keyFile));
|
||||
log.info("DKIM: key loaded for {}", domain);
|
||||
} else {
|
||||
// Генерируем новые ключи
|
||||
KeyPair keyPair = generateKeyPair();
|
||||
privateKey = keyPair.getPrivate();
|
||||
publicKey = keyPair.getPublic();
|
||||
saveKeys(privateKeyFile, publicKeyFile, keyPair);
|
||||
log.info("DKIM: сгенерированы новые ключи для домена {}", domain);
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
privateKey = (RSAPrivateKey) generator.generateKeyPair().getPrivate();
|
||||
String pem = "-----BEGIN PRIVATE KEY-----\n"
|
||||
+ Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded())
|
||||
+ "\n-----END PRIVATE KEY-----\n";
|
||||
Files.writeString(keyFile, pem);
|
||||
restrict(keyFile);
|
||||
log.info("DKIM: new key generated for {}", domain);
|
||||
}
|
||||
|
||||
// Формируем публичный ключ в формате DNS TXT-записи
|
||||
publicKeyBase64 = encodePublicKeyForDns(publicKey);
|
||||
log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain);
|
||||
log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64);
|
||||
|
||||
publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyOf(privateKey));
|
||||
log.info("DKIM DNS record — TXT {}._domainkey.{}: {}", selector, domain, dnsRecord());
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка инициализации DKIM", e);
|
||||
throw new IllegalStateException("DKIM key cannot be loaded from " + keysDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует RSA-ключ 2048 бит для DKIM.
|
||||
*/
|
||||
private KeyPair generateKeyPair() throws NoSuchAlgorithmException {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
return keyPairGenerator.generateKeyPair();
|
||||
/** TXT value to publish at {@code <selector>._domainkey.<domain>}. */
|
||||
public String dnsRecord() {
|
||||
return publicKeyBase64 == null ? null : "v=DKIM1; k=rsa; p=" + publicKeyBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет ключи в PEM-формат.
|
||||
* Signs the message when it is from our domain; otherwise returns it unchanged.
|
||||
* The result is the exact byte sequence to send — do not re-serialize it.
|
||||
*/
|
||||
private void saveKeys(Path privateKeyFile, Path publicKeyFile, KeyPair keyPair)
|
||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
try (FileWriter fw = new FileWriter(privateKeyFile.toFile());
|
||||
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
|
||||
pemWriter.writeObject(keyPair.getPrivate());
|
||||
pemWriter.flush();
|
||||
}
|
||||
|
||||
try (FileWriter fw = new FileWriter(publicKeyFile.toFile());
|
||||
JcaPEMWriter pemWriter = new JcaPEMWriter(fw)) {
|
||||
pemWriter.writeObject(keyPair.getPublic());
|
||||
pemWriter.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает ключи из PEM-файлов.
|
||||
*/
|
||||
private void loadKeys(Path privateKeyFile, Path publicKeyFile)
|
||||
throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
String pkcs8Private = Files.readString(privateKeyFile);
|
||||
String derEncoded = pkcs8Private
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(derEncoded);
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
privateKey = keyFactory.generatePrivate(spec);
|
||||
|
||||
String pkcs8Public = Files.readString(publicKeyFile);
|
||||
String derEncodedPublic = pkcs8Public
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decodedPublic = Base64.getDecoder().decode(derEncodedPublic);
|
||||
X509EncodedKeySpec pubSpec = new X509EncodedKeySpec(decodedPublic);
|
||||
publicKey = keyFactory.generatePublic(pubSpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Кодирует публичный ключ для DNS TXT-записи.
|
||||
*/
|
||||
private String encodePublicKeyForDns(PublicKey key) {
|
||||
byte[] encoded = key.getEncoded();
|
||||
return Base64.getEncoder().withoutPadding().encodeToString(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывает MIME-сообщение DKIM-подписью.
|
||||
* Добавляет заголовок DKIM-Signature в письмо.
|
||||
*
|
||||
* @param mimeMessage MIME-сообщение для подписи
|
||||
* @throws MessagingException если не удалось добавить заголовок
|
||||
*/
|
||||
public void signMessage(MimeMessage mimeMessage) throws MessagingException {
|
||||
public byte[] sign(byte[] message) throws MessagingException, IOException {
|
||||
if (!enabled || privateKey == null) {
|
||||
return;
|
||||
return message;
|
||||
}
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(message));
|
||||
if (mime.getFrom() == null || mime.getFrom().length == 0
|
||||
|| !(mime.getFrom()[0] instanceof InternetAddress from)
|
||||
|| !domain.equals(domainOf(from.getAddress()))) {
|
||||
return message;
|
||||
}
|
||||
DkimSigner signer = new DkimSigner(domain, selector, privateKey);
|
||||
signer.setCheckDomainKey(false); // the DNS record may not be published yet (tests, new server)
|
||||
signer.setSigningAlgorithm(SigningAlgorithm.SHA256_WITH_RSA);
|
||||
signer.setHeaderCanonicalization(Canonicalization.RELAXED);
|
||||
signer.setBodyCanonicalization(Canonicalization.RELAXED);
|
||||
signer.setLengthParam(false);
|
||||
signer.setCopyHeaderFields(false);
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(message.length + 1024);
|
||||
DkimMessage signed = new DkimMessage(mime, signer);
|
||||
signed.setAllow8bitMIME(true);
|
||||
signed.writeTo(out);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/** Checks DKIM signatures of an incoming message (public key from the signer's DNS). */
|
||||
public Verification verify(byte[] message) {
|
||||
try {
|
||||
// Извлекаем заголовки для подписи
|
||||
String from = extractHeader(mimeMessage, "From");
|
||||
String subject = extractHeader(mimeMessage, "Subject");
|
||||
String date = extractHeader(mimeMessage, "Date");
|
||||
String to = extractHeader(mimeMessage, "To");
|
||||
String messageId = extractHeader(mimeMessage, "Message-ID");
|
||||
|
||||
// Извлекаем тело сообщения
|
||||
String body = extractBody(mimeMessage);
|
||||
|
||||
// Формируем канонизированное тело (relaxed канонизация)
|
||||
String canonicalBody = canonicalizeBody(body);
|
||||
|
||||
// Формируем канонизированные заголовки
|
||||
String headerCanonicalization = "r";
|
||||
String bodyCanonicalization = "r";
|
||||
String algorithm = "rsa-sha256";
|
||||
|
||||
// Строим заголовки для подписи
|
||||
StringBuilder headersToSign = new StringBuilder();
|
||||
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
|
||||
String[] headerValues = {from, subject, date, to, messageId};
|
||||
|
||||
for (int i = 0; i < headerNames.length; i++) {
|
||||
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
|
||||
List<SignatureRecord> valid;
|
||||
synchronized (verifier) { // DKIMVerifier keeps results between calls
|
||||
verifier.resetResults();
|
||||
valid = verifier.verify(new ByteArrayInputStream(message));
|
||||
}
|
||||
|
||||
// Вычисляем хэш тела
|
||||
byte[] bodyHash = hashBody(canonicalBody);
|
||||
|
||||
// Формируем signing input
|
||||
String headerCanon = headersToSign.toString();
|
||||
byte[] signingInput = (headerCanon + "bh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash) + "\rbh=" + Base64.getEncoder().withoutPadding().encodeToString(bodyHash)).getBytes();
|
||||
|
||||
// Подписываем
|
||||
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
|
||||
signature.initSign(privateKey);
|
||||
signature.update(signingInput);
|
||||
byte[] signatureBytes = signature.sign();
|
||||
|
||||
// Формируем DKIM-Signature заголовок
|
||||
String b64Signature = Base64.getEncoder().withoutPadding().encodeToString(signatureBytes);
|
||||
String b64BodyHash = Base64.getEncoder().withoutPadding().encodeToString(bodyHash);
|
||||
|
||||
StringBuilder dkimHeader = new StringBuilder();
|
||||
dkimHeader.append("v=1; ");
|
||||
dkimHeader.append("a=").append(algorithm).append("; ");
|
||||
dkimHeader.append("d=").append(domain).append("; ");
|
||||
dkimHeader.append("s=").append(DKIM_SELECTOR).append("; ");
|
||||
dkimHeader.append("c=").append(headerCanonicalization).append("/").append(bodyCanonicalization).append("; ");
|
||||
dkimHeader.append("h=").append(String.join(":", headerNames)).append("; ");
|
||||
dkimHeader.append("bh=").append(b64BodyHash).append("; ");
|
||||
dkimHeader.append("b=").append(b64Signature);
|
||||
|
||||
mimeMessage.setHeader("DKIM-Signature", dkimHeader.toString());
|
||||
log.debug("DKIM: сообщение подписано для домена {}", domain);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DKIM: ошибка подписи сообщения", e);
|
||||
// Не бросаем исключение — письмо будет отправлено без подписи
|
||||
if (valid == null) {
|
||||
return Verification.NONE;
|
||||
}
|
||||
if (valid.isEmpty()) {
|
||||
return new Verification(true, false, null);
|
||||
}
|
||||
return new Verification(true, true, valid.get(0).getDToken().toString().toLowerCase(Locale.ROOT));
|
||||
} catch (FailException e) {
|
||||
log.info("DKIM: signature failed: {}", e.getMessage());
|
||||
return new Verification(true, false, null);
|
||||
} catch (IOException e) {
|
||||
log.warn("DKIM: message cannot be read: {}", e.getMessage());
|
||||
return new Verification(true, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует DKIM-подпись входящего сообщения.
|
||||
*
|
||||
* @param mimeMessage MIME-сообщение с DKIM-подписью
|
||||
* @return true если подпись валидна, false если подпись отсутствует или невалидна
|
||||
*/
|
||||
public boolean verifyMessage(MimeMessage mimeMessage) {
|
||||
if (!enabled) {
|
||||
return true; // Если DKIM отключён, считаем все письма валидными
|
||||
}
|
||||
static String domainOf(String address) {
|
||||
int at = address == null ? -1 : address.lastIndexOf('@');
|
||||
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static RSAPrivateKey readPrivateKey(String pem) throws IOException {
|
||||
try (Reader reader = new StringReader(pem); PEMParser parser = new PEMParser(reader)) {
|
||||
Object object = parser.readObject();
|
||||
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
|
||||
// PKCS#8 ("PRIVATE KEY") and the older PKCS#1 ("RSA PRIVATE KEY") written by earlier versions
|
||||
if (object instanceof PEMKeyPair pair) {
|
||||
return (RSAPrivateKey) converter.getKeyPair(pair).getPrivate();
|
||||
}
|
||||
if (object instanceof PrivateKeyInfo info) {
|
||||
return (RSAPrivateKey) converter.getPrivateKey(info);
|
||||
}
|
||||
throw new IOException("Not an RSA private key: " + (object == null ? "empty file" : object.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] publicKeyOf(RSAPrivateKey key) throws Exception {
|
||||
RSAPrivateCrtKey crt = (RSAPrivateCrtKey) key;
|
||||
return KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new RSAPublicKeySpec(crt.getModulus(), crt.getPublicExponent()))
|
||||
.getEncoded();
|
||||
}
|
||||
|
||||
private static void restrict(Path file) {
|
||||
try {
|
||||
String dkimHeader = mimeMessage.getHeader("DKIM-Signature")[0];
|
||||
if (dkimHeader == null || dkimHeader.isEmpty()) {
|
||||
log.debug("DKIM: подпись не найдена в сообщении");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Извлекаем домен из подписи
|
||||
String domain = extractDkimValue(dkimHeader, "d=");
|
||||
if (domain == null || domain.isEmpty()) {
|
||||
log.warn("DKIM: не удалось извлечь домен из подписи");
|
||||
return false;
|
||||
}
|
||||
|
||||
// В реальном мире здесь мы бы:
|
||||
// 1. Сделали DNS-запрос для _domainkey.<domain>
|
||||
// 2. Получили публичный ключ
|
||||
// 3. Проверили подпись
|
||||
//
|
||||
// Для локального сервера мы проверяем, что подпись присутствует
|
||||
// и имеет корректный формат
|
||||
String version = extractDkimValue(dkimHeader, "v=");
|
||||
String algorithm = extractDkimValue(dkimHeader, "a=");
|
||||
String selector = extractDkimValue(dkimHeader, "s=");
|
||||
String signature = extractDkimValue(dkimHeader, "b=");
|
||||
|
||||
if (version == null || !version.equals("1")) {
|
||||
log.warn("DKIM: неверная версия подписи");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (algorithm == null || !algorithm.startsWith("rsa-")) {
|
||||
log.warn("DKIM: неподдерживаемый алгоритм: {}", algorithm);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (signature == null || signature.isEmpty()) {
|
||||
log.warn("DKIM: подпись пуста");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Проверяем, что подпись — валидный Base64
|
||||
try {
|
||||
Base64.getDecoder().decode(signature);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("DKIM: подпись не является валидным Base64");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info("DKIM: подпись найдена для домена {} (селектор: {})", domain, selector);
|
||||
|
||||
// Если письмо подписано нашим собственным ключом, проверяем полностью
|
||||
if (domain.equals(this.domain) && privateKey != null) {
|
||||
return verifyLocalSignature(mimeMessage, dkimHeader);
|
||||
}
|
||||
|
||||
// Для внешних доменов проверяем только формат
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DKIM: ошибка верификации", e);
|
||||
return false;
|
||||
Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-------"));
|
||||
} catch (UnsupportedOperationException | IOException ignored) {
|
||||
// not a POSIX file system
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует подпись локального домена.
|
||||
*/
|
||||
private boolean verifyLocalSignature(MimeMessage mimeMessage, String dkimHeader) {
|
||||
try {
|
||||
String from = extractHeader(mimeMessage, "From");
|
||||
String subject = extractHeader(mimeMessage, "Subject");
|
||||
String date = extractHeader(mimeMessage, "Date");
|
||||
String to = extractHeader(mimeMessage, "To");
|
||||
String messageId = extractHeader(mimeMessage, "Message-ID");
|
||||
String body = extractBody(mimeMessage);
|
||||
String canonicalBody = canonicalizeBody(body);
|
||||
|
||||
StringBuilder headersToSign = new StringBuilder();
|
||||
String[] headerNames = {"from", "subject", "date", "to", "message-id"};
|
||||
String[] headerValues = {from, subject, date, to, messageId};
|
||||
|
||||
for (int i = 0; i < headerNames.length; i++) {
|
||||
headersToSign.append(headerNames[i]).append(":").append(headerValues[i]).append("\r\n");
|
||||
}
|
||||
|
||||
byte[] bodyHash = hashBody(canonicalBody);
|
||||
String b64BodyHash = extractDkimValue(dkimHeader, "bh=");
|
||||
String b64Signature = extractDkimValue(dkimHeader, "b=");
|
||||
|
||||
// Проверяем хэш тела
|
||||
if (b64BodyHash != null) {
|
||||
byte[] claimedBodyHash = Base64.getDecoder().decode(b64BodyHash);
|
||||
if (!MessageDigest.isEqual(bodyHash, claimedBodyHash)) {
|
||||
log.warn("DKIM: хэш тела не совпадает");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем подпись
|
||||
String headerCanon = headersToSign.toString();
|
||||
byte[] signingInput = (headerCanon + "bh=" + b64BodyHash + "\rbh=" + b64BodyHash).getBytes();
|
||||
|
||||
Signature signature = Signature.getInstance("SHA256withRSA", "BC");
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(signingInput);
|
||||
|
||||
byte[] signatureBytes = Base64.getDecoder().decode(b64Signature);
|
||||
boolean valid = signature.verify(signatureBytes);
|
||||
|
||||
if (valid) {
|
||||
log.info("DKIM: подпись локального домена верифицирована");
|
||||
} else {
|
||||
log.warn("DKIM: подпись локального домена не верифицирована");
|
||||
}
|
||||
|
||||
return valid;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DKIM: ошибка верификации локальной подписи", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает значение параметра из DKIM-заголовка.
|
||||
*/
|
||||
private String extractDkimValue(String dkimHeader, String param) {
|
||||
int index = dkimHeader.indexOf(param);
|
||||
if (index == -1) return null;
|
||||
|
||||
int start = index + param.length();
|
||||
int end = dkimHeader.indexOf(";", start);
|
||||
if (end == -1) end = dkimHeader.length();
|
||||
|
||||
return dkimHeader.substring(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает заголовок из MIME-сообщения.
|
||||
*/
|
||||
private String extractHeader(MimeMessage mimeMessage, String name) throws MessagingException {
|
||||
String[] headers = mimeMessage.getHeader(name);
|
||||
if (headers == null || headers.length == 0) return "";
|
||||
return headers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает тело MIME-сообщения.
|
||||
*/
|
||||
private String extractBody(MimeMessage mimeMessage) throws MessagingException, IOException {
|
||||
Object content = mimeMessage.getContent();
|
||||
if (content instanceof String) {
|
||||
return (String) content;
|
||||
} else if (content instanceof jakarta.mail.internet.MimeMultipart) {
|
||||
jakarta.mail.internet.MimeMultipart multipart = (jakarta.mail.internet.MimeMultipart) content;
|
||||
StringBuilder body = new StringBuilder();
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
var part = multipart.getBodyPart(i);
|
||||
String contentType = part.getContentType();
|
||||
if (contentType != null && contentType.toLowerCase().contains("text")) {
|
||||
Object partContent = part.getContent();
|
||||
if (partContent instanceof String) {
|
||||
body.append(partContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return body.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Канонизация тела сообщения (relaxed канонизация).
|
||||
* Убирает лишние пробелы и переводы строк.
|
||||
*/
|
||||
private String canonicalizeBody(String body) {
|
||||
if (body == null) return "";
|
||||
// Relaxed: нормализуем CRLF в LF, убираем пробелы в конце строк
|
||||
return body.replaceAll("\\r\\n", "\n")
|
||||
.replaceAll("\\r", "\n")
|
||||
.replaceAll(" +\n", "\n")
|
||||
.replaceAll("\n+", "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет SHA-256 хэш тела сообщения.
|
||||
*/
|
||||
private byte[] hashBody(String body) throws NoSuchAlgorithmException {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return digest.digest(body.getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает публичный ключ в формате DNS TXT-записи.
|
||||
* Используется для публикации в DNS.
|
||||
*/
|
||||
public String getPublicKeyForDns() {
|
||||
if (publicKeyBase64 == null) return null;
|
||||
return "v=DKIM1; k=rsa; p=" + publicKeyBase64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает домен DKIM.
|
||||
*/
|
||||
public String getDomain() {
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает селектор DKIM.
|
||||
*/
|
||||
public String getSelector() {
|
||||
return DKIM_SELECTOR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.delivery.DeliveryWorker;
|
||||
import com.mailserver.delivery.OutboundMessage;
|
||||
import jakarta.mail.BodyPart;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Multipart;
|
||||
import jakarta.mail.Part;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Stores a message in one of our mailboxes. Mail from outside is checked first — DKIM (signature),
|
||||
* SPF (the connecting IP against the envelope sender's domain), DMARC (policy of the From domain);
|
||||
* a failure marks it as spam. Mail from our own logged-in users is trusted.
|
||||
*/
|
||||
@Service
|
||||
public class LocalDelivery implements DeliveryWorker.BounceSender {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LocalDelivery.class);
|
||||
|
||||
private final MailService mailService;
|
||||
private final DkimService dkimService;
|
||||
private final SpfService spfService;
|
||||
private final DmarcService dmarcService;
|
||||
private final String hostname;
|
||||
|
||||
public LocalDelivery(MailService mailService, DkimService dkimService, SpfService spfService,
|
||||
DmarcService dmarcService, @Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||
this.mailService = mailService;
|
||||
this.dkimService = dkimService;
|
||||
this.spfService = spfService;
|
||||
this.dmarcService = dmarcService;
|
||||
this.hostname = hostname;
|
||||
}
|
||||
|
||||
/** Blocking: call from a worker thread. */
|
||||
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
|
||||
throws MessagingException, IOException {
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
|
||||
|
||||
String senderEmail = envelopeFrom;
|
||||
String senderName = null;
|
||||
if (mime.getFrom() != null && mime.getFrom().length > 0 && mime.getFrom()[0] instanceof InternetAddress from) {
|
||||
senderEmail = from.getAddress();
|
||||
senderName = from.getPersonal();
|
||||
}
|
||||
String subject = mime.getSubject() == null ? "" : mime.getSubject();
|
||||
Body body = body(mime);
|
||||
|
||||
boolean spam = false;
|
||||
if (!trusted) {
|
||||
DkimService.Verification dkim = dkimService.verify(raw);
|
||||
String spfSender = envelopeFrom == null || envelopeFrom.isEmpty() ? senderEmail : envelopeFrom;
|
||||
SpfService.SpfResult spf = spfService.checkSpf(spfSender, remoteIp);
|
||||
boolean spfPassed = spf == SpfService.SpfResult.PASS;
|
||||
DmarcService.DmarcResult dmarc = dmarcService.checkDmarc(senderEmail, remoteIp, spfPassed, dkim.passed(), dkim.domain());
|
||||
spam = dmarcService.shouldMarkAsSpam(dmarc) || spf == SpfService.SpfResult.FAIL || (dkim.signed() && !dkim.passed());
|
||||
log.info("Incoming {} -> {} from {}: DKIM {}, SPF {}, DMARC {}{}", senderEmail, mailbox, remoteIp,
|
||||
dkim.signed() ? (dkim.passed() ? "pass " + dkim.domain() : "fail") : "none", spf, dmarc, spam ? " → spam" : "");
|
||||
}
|
||||
|
||||
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam).block();
|
||||
}
|
||||
|
||||
/** Non-delivery report into the sender's inbox — only when the sender is our mailbox. */
|
||||
@Override
|
||||
public void bounce(OutboundMessage message, String reason) {
|
||||
String text = "Your message to " + message.getRcptTo() + " could not be delivered.\n\n"
|
||||
+ "Reason: " + reason + "\n\nAttempts: " + message.getAttempts() + "\n";
|
||||
try {
|
||||
mailService.receiveMessage("mailer-daemon@" + hostname, "Mail Delivery System", message.getMailFrom(),
|
||||
"Undelivered: message to " + message.getRcptTo(), text, false, false).block();
|
||||
} catch (IllegalArgumentException notOurs) {
|
||||
log.info("Bounce not stored: sender {} is not a local mailbox", message.getMailFrom());
|
||||
}
|
||||
}
|
||||
|
||||
record Body(String text, boolean html) {
|
||||
}
|
||||
|
||||
/** HTML part when the message has one, otherwise the plain text; nested multiparts are walked. */
|
||||
static Body body(Part part) throws MessagingException, IOException {
|
||||
String html = find(part, "text/html");
|
||||
if (html != null) {
|
||||
return new Body(html, true);
|
||||
}
|
||||
String plain = find(part, "text/plain");
|
||||
return new Body(plain == null ? "" : plain, false);
|
||||
}
|
||||
|
||||
private static String find(Part part, String type) throws MessagingException, IOException {
|
||||
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||
return null;
|
||||
}
|
||||
if (part.isMimeType(type)) {
|
||||
Object content = part.getContent();
|
||||
if (content instanceof String s) {
|
||||
return s;
|
||||
}
|
||||
if (content instanceof InputStream in) {
|
||||
try (in) {
|
||||
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (part.isMimeType("multipart/*") && part.getContent() instanceof Multipart multipart) {
|
||||
for (int i = 0; i < multipart.getCount(); i++) {
|
||||
BodyPart child = multipart.getBodyPart(i);
|
||||
String found = find(child, type);
|
||||
if (found != null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import com.mailserver.domain.Message;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.AttachmentResponse;
|
||||
import com.mailserver.dto.MessageResponse;
|
||||
import com.mailserver.dto.SendRequest;
|
||||
import com.mailserver.repository.AttachmentRepository;
|
||||
import com.mailserver.repository.ContactRepository;
|
||||
import com.mailserver.repository.FolderRepository;
|
||||
@@ -15,16 +14,14 @@ import com.mailserver.repository.MessageRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -36,8 +33,6 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import reactor.netty.Connection;
|
||||
import reactor.netty.tcp.TcpClient;
|
||||
|
||||
@Service
|
||||
public class MailService {
|
||||
@@ -50,64 +45,44 @@ public class MailService {
|
||||
private final AttachmentRepository attachmentRepository;
|
||||
private final ContactRepository contactRepository;
|
||||
private final SpamFilterService spamFilterService;
|
||||
private final DkimService dkimService;
|
||||
|
||||
@Value("${mailserver.attachments.dir:./attachments}")
|
||||
private String attachmentsDir;
|
||||
|
||||
@Value("${mailserver.smtp.port:3025}")
|
||||
private int smtpPort;
|
||||
|
||||
@Value("${mailserver.smtp.hostname:localhost}")
|
||||
private String smtpHostname;
|
||||
|
||||
public MailService(MessageRepository messageRepository, FolderRepository folderRepository,
|
||||
UserRepository userRepository, AttachmentRepository attachmentRepository,
|
||||
ContactRepository contactRepository,
|
||||
SpamFilterService spamFilterService,
|
||||
DkimService dkimService) {
|
||||
SpamFilterService spamFilterService) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.folderRepository = folderRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.attachmentRepository = attachmentRepository;
|
||||
this.contactRepository = contactRepository;
|
||||
this.spamFilterService = spamFilterService;
|
||||
this.dkimService = dkimService;
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> sendMessage(String fromEmail, SendRequest request) {
|
||||
return userRepository.findByEmail(fromEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Sender not found: " + fromEmail)))
|
||||
.flatMap(fromUser ->
|
||||
folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
||||
.flatMap(sentFolder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(sentFolder.getId());
|
||||
message.setSenderEmail(fromEmail);
|
||||
message.setSenderName(fromUser.getDisplayName());
|
||||
message.setRecipientEmail(request.getTo());
|
||||
message.setSubject(request.getSubject());
|
||||
message.setBody(request.getBody());
|
||||
message.setHtml(request.isHtml());
|
||||
message.setRead(true);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
message.setMessageSize((long) (request.getSubject().length() + request.getBody().length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.flatMap(message ->
|
||||
sendViaSmtp(fromEmail, request.getTo(), request.getSubject(), request.getBody(),
|
||||
request.isHtml(), message.getMessageId())
|
||||
.doOnSuccess(v -> log.info("Email sent via SMTP: {} -> {}", fromEmail, request.getTo()))
|
||||
.onErrorResume(e -> {
|
||||
log.error("SMTP send failed: {}", e.getMessage(), e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.thenReturn(message)
|
||||
)
|
||||
.map(this::toResponse)
|
||||
);
|
||||
/** Copy of a message we sent, in the sender's "Sent" folder. */
|
||||
public Mono<MessageResponse> saveSent(User fromUser, String to, String subject, String body, boolean html, String messageId) {
|
||||
return folderRepository.findByUserIdAndName(fromUser.getId(), "Sent")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("Sent folder not found")))
|
||||
.flatMap(sentFolder -> {
|
||||
Message message = new Message();
|
||||
message.setFolderId(sentFolder.getId());
|
||||
message.setSenderEmail(fromUser.getEmail());
|
||||
message.setSenderName(fromUser.getDisplayName());
|
||||
message.setRecipientEmail(to);
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(html);
|
||||
message.setRead(true);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(messageId);
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message);
|
||||
})
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
@@ -149,13 +124,15 @@ public class MailService {
|
||||
|
||||
public Flux<MessageResponse> getMessages(Long folderId, Long userId, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.findByFolderId(folderId, pageable)
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.findByFolderId(folder.getId(), pageable))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessages(Long folderId, Long userId, String keyword, int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.searchInFolder(folderId, keyword, pageable)
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchInFolder(folder.getId(), keyword, pageable))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
@@ -164,17 +141,17 @@ public class MailService {
|
||||
String sender, String recipient,
|
||||
int page, int size) {
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "receivedAt"));
|
||||
return messageRepository.searchWithFilters(folderId, subject, body, sender, recipient, pageable)
|
||||
return ownedFolder(folderId, userId)
|
||||
.flatMapMany(folder -> messageRepository.searchWithFilters(folder.getId(), subject, body, sender, recipient, pageable))
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.map(this::toResponse);
|
||||
return ownedMessage(messageId, userId).map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
message.setRead(true);
|
||||
return messageRepository.save(message).then();
|
||||
@@ -182,7 +159,29 @@ public class MailService {
|
||||
}
|
||||
|
||||
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
||||
return messageRepository.deleteById(messageId);
|
||||
return ownedMessage(messageId, userId).flatMap(messageRepository::delete);
|
||||
}
|
||||
|
||||
/** The folder, when it belongs to the user; otherwise 404 — never someone else's mail. */
|
||||
private Mono<Folder> ownedFolder(Long folderId, Long userId) {
|
||||
return folderRepository.findById(folderId)
|
||||
.filter(folder -> folder.getUserId().equals(userId))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Folder not found")));
|
||||
}
|
||||
|
||||
private Mono<Message> ownedMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.filterWhen(message -> folderRepository.findById(message.getFolderId())
|
||||
.map(folder -> folder.getUserId().equals(userId))
|
||||
.defaultIfEmpty(false))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Message not found")));
|
||||
}
|
||||
|
||||
private Mono<Attachment> ownedAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.filterWhen(attachment -> ownedMessage(attachment.getMessageId(), userId).hasElement()
|
||||
.onErrorReturn(false))
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Attachment not found")));
|
||||
}
|
||||
|
||||
private MessageResponse toResponse(Message message) {
|
||||
@@ -206,7 +205,7 @@ public class MailService {
|
||||
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
||||
String originalFilename, String contentType,
|
||||
long sizeBytes, String filePath) {
|
||||
return messageRepository.findById(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMap(message -> {
|
||||
Attachment attachment = new Attachment();
|
||||
attachment.setMessageId(messageId);
|
||||
@@ -223,30 +222,24 @@ public class MailService {
|
||||
return attachmentsDir;
|
||||
}
|
||||
|
||||
public String getAttachmentFilePath(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.map(Attachment::getFilePath)
|
||||
.block();
|
||||
}
|
||||
|
||||
public AttachmentResponse getAttachmentInfo(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.map(this::toAttachmentResponse)
|
||||
.block();
|
||||
/** The attachment record (file path, name, type) — only the owner's. */
|
||||
public Mono<Attachment> getAttachment(Long attachmentId, Long userId) {
|
||||
return ownedAttachment(attachmentId, userId);
|
||||
}
|
||||
|
||||
public Flux<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
|
||||
return attachmentRepository.findByMessageId(messageId)
|
||||
return ownedMessage(messageId, userId)
|
||||
.flatMapMany(message -> attachmentRepository.findByMessageId(message.getId()))
|
||||
.map(this::toAttachmentResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
return ownedAttachment(attachmentId, userId)
|
||||
.flatMap(attachment -> {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
||||
} catch (IOException e) {
|
||||
// Игнорируем
|
||||
log.warn("Attachment file {} not deleted: {}", attachment.getFilePath(), e.getMessage());
|
||||
}
|
||||
return attachmentRepository.delete(attachment);
|
||||
});
|
||||
@@ -261,132 +254,6 @@ public class MailService {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private Mono<Void> sendViaSmtp(String from, String to, String subject, String body,
|
||||
boolean isHtml, String messageId) {
|
||||
TcpClient client = TcpClient.create()
|
||||
.host(smtpHostname)
|
||||
.port(smtpPort)
|
||||
.option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000)
|
||||
.option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
|
||||
|
||||
Properties props = new Properties();
|
||||
Session mailSession = Session.getDefaultInstance(props);
|
||||
MimeMessage mimeMessage = new MimeMessage(mailSession);
|
||||
try {
|
||||
mimeMessage.setFrom(from);
|
||||
mimeMessage.setRecipient(jakarta.mail.Message.RecipientType.TO, new jakarta.mail.internet.InternetAddress(to));
|
||||
mimeMessage.setSubject(subject);
|
||||
mimeMessage.setText(body, isHtml ? "text/html" : "text/plain", "UTF-8");
|
||||
mimeMessage.setHeader("Message-ID", "<" + messageId + "@" + smtpHostname + ">");
|
||||
mimeMessage.setSentDate(new java.util.Date());
|
||||
|
||||
dkimService.signMessage(mimeMessage);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
mimeMessage.writeTo(baos);
|
||||
String mimeContent = baos.toString(StandardCharsets.UTF_8);
|
||||
|
||||
StringBuilder dataBody = new StringBuilder();
|
||||
String[] lines = mimeContent.split("\\r?\\n");
|
||||
for (String mimeLine : lines) {
|
||||
if (mimeLine.startsWith(".")) {
|
||||
dataBody.append(".").append(mimeLine);
|
||||
} else {
|
||||
dataBody.append(mimeLine);
|
||||
}
|
||||
dataBody.append("\r\n");
|
||||
}
|
||||
dataBody.append(".\r\n");
|
||||
|
||||
// SMTP — последовательный протокол: команда -> ответ -> команда -> ответ
|
||||
// connection.inbound().receive() можно подписать только ОДИН раз,
|
||||
// поэтому используем Sinks.Many с directBestEffort для шаринга ответов
|
||||
return client.connect()
|
||||
.flatMap(connection -> {
|
||||
// Sinks.Many — один поток чтения, много подписчиков через multicast
|
||||
Sinks.Many<String> responseSink = Sinks.many().replay().limit(1);
|
||||
|
||||
// Подписываемся на inbound ОДИН раз и пишем все ответы в sink
|
||||
connection.inbound()
|
||||
.receive()
|
||||
.asString(StandardCharsets.UTF_8)
|
||||
.doOnNext(line -> {
|
||||
String trimmed = line.trim();
|
||||
log.debug("SMTP клиент ответ: {}", trimmed);
|
||||
responseSink.tryEmitNext(trimmed);
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
// Собираем все команды в список для последовательной отправки
|
||||
List<String> commands = new ArrayList<>();
|
||||
commands.add("EHLO " + smtpHostname + "\r\n");
|
||||
commands.add("MAIL FROM:<" + from + ">\r\n");
|
||||
commands.add("RCPT TO:<" + to + ">\r\n");
|
||||
commands.add("DATA\r\n");
|
||||
commands.add(dataBody.toString());
|
||||
commands.add("QUIT\r\n");
|
||||
|
||||
// Ожидаемые коды ответов для каждой команды (включая banner)
|
||||
List<String> expectedCodes = new ArrayList<>();
|
||||
expectedCodes.add("220"); // banner
|
||||
expectedCodes.add("250"); // EHLO
|
||||
expectedCodes.add("250"); // MAIL FROM
|
||||
expectedCodes.add("250"); // RCPT TO
|
||||
expectedCodes.add("354"); // DATA
|
||||
expectedCodes.add("250"); // тело DATA (завершение)
|
||||
expectedCodes.add("221"); // QUIT
|
||||
|
||||
// Последовательно отправляем команды и ждём ответы
|
||||
Mono<Void> send = Mono.empty();
|
||||
for (int i = 0; i < commands.size(); i++) {
|
||||
final String cmd = commands.get(i);
|
||||
final String expectedCode = expectedCodes.get(i);
|
||||
|
||||
send = send.then(sendAndAwait(connection, cmd, responseSink, expectedCode));
|
||||
}
|
||||
|
||||
return send.then(Mono.fromRunnable(() -> connection.dispose()));
|
||||
});
|
||||
|
||||
} catch (MessagingException | IOException e) {
|
||||
return Mono.error(new RuntimeException("Failed to prepare SMTP message", e));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет SMTP команду и ждёт ответ с указанным кодом.
|
||||
* Для многострочных ответов (EHLO) читает до строки без '-' в конце кода.
|
||||
*/
|
||||
private Mono<Void> sendAndAwait(Connection connection, String command,
|
||||
Sinks.Many<String> responseSink,
|
||||
String expectedCode) {
|
||||
log.debug("SMTP клиент команда: {}", command.trim());
|
||||
|
||||
// Отправляем команду
|
||||
Mono<Void> send = connection.outbound()
|
||||
.sendString(Mono.just(command))
|
||||
.then();
|
||||
|
||||
// Ждём ответ из sink — каждый вызов получает свой Flux подписчика
|
||||
Mono<Void> readResponse = responseSink.asFlux()
|
||||
.takeWhile(line -> {
|
||||
if (expectedCode != null && line.startsWith(expectedCode + "-")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.doOnNext(line -> {
|
||||
if (expectedCode != null && !line.startsWith(expectedCode)) {
|
||||
log.warn("SMTP неожиданный ответ (ожидалось {}): {}", expectedCode, line);
|
||||
}
|
||||
})
|
||||
.then()
|
||||
.timeout(java.time.Duration.ofSeconds(30))
|
||||
.onErrorResume(e -> Mono.error(new RuntimeException("SMTP response timeout", e)));
|
||||
|
||||
return send.then(readResponse);
|
||||
}
|
||||
|
||||
// --- Contacts ---
|
||||
|
||||
public Flux<Contact> getContacts(Long userId) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.dto.MessageResponse;
|
||||
import com.mailserver.dto.SendRequest;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import com.mailserver.smtp.MailRouter;
|
||||
import com.mailserver.smtp.SmtpBackend;
|
||||
import jakarta.mail.Message;
|
||||
import jakarta.mail.Session;
|
||||
import jakarta.mail.internet.InternetAddress;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
|
||||
/** "Send" in the web client: builds the MIME message and routes it like an authenticated SMTP submission. */
|
||||
@Service
|
||||
public class SubmissionService {
|
||||
|
||||
private final UserRepository users;
|
||||
private final MailService mailService;
|
||||
private final MailRouter router;
|
||||
private final String hostname;
|
||||
|
||||
public SubmissionService(UserRepository users, MailService mailService, MailRouter router,
|
||||
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||
this.users = users;
|
||||
this.mailService = mailService;
|
||||
this.router = router;
|
||||
this.hostname = hostname;
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> send(String fromEmail, SendRequest request) {
|
||||
String to = request.getTo().trim().toLowerCase(Locale.ROOT);
|
||||
return users.findByEmail(fromEmail)
|
||||
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||
.flatMap(user -> Mono.fromCallable(() -> {
|
||||
SmtpBackend.Route route = router.route(to, user.getEmail());
|
||||
switch (route) {
|
||||
case LOCAL, OUTBOUND -> { }
|
||||
case UNKNOWN_USER -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No such mailbox: " + to);
|
||||
case OUTBOUND_DISABLED -> throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||
"Sending outside is disabled on this server");
|
||||
case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay");
|
||||
}
|
||||
String messageId = UUID.randomUUID() + "@" + hostname;
|
||||
MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()));
|
||||
mime.setFrom(new InternetAddress(user.getEmail(), user.getDisplayName(), "UTF-8"));
|
||||
mime.setRecipient(Message.RecipientType.TO, new InternetAddress(to));
|
||||
mime.setSubject(request.getSubject(), "UTF-8");
|
||||
mime.setText(request.getBody(), "UTF-8", request.isHtml() ? "html" : "plain");
|
||||
mime.setSentDate(new Date());
|
||||
mime.saveChanges();
|
||||
mime.setHeader("Message-ID", "<" + messageId + ">");
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
mime.writeTo(bytes);
|
||||
router.accept(new SmtpBackend.Envelope("127.0.0.1", hostname, user.getEmail(), user.getEmail(),
|
||||
List.of(new SmtpBackend.Recipient(to, route))), bytes.toByteArray());
|
||||
return messageId;
|
||||
}).subscribeOn(Schedulers.boundedElastic())
|
||||
.flatMap(messageId -> mailService.saveSent(user, to, request.getSubject(), request.getBody(),
|
||||
request.isHtml(), messageId)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user