Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -0,0 +1,458 @@
|
||||
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.internet.MimeMessage;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.*;
|
||||
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.util.Base64;
|
||||
|
||||
/**
|
||||
* Сервис для DKIM (DomainKeys Identified Mail) подписи и верификации писем.
|
||||
*
|
||||
* DKIM добавляет криптографическую подпись к каждому исходящему письму,
|
||||
* чтобы получатель мог проверить, что письмо действительно отправлено
|
||||
* владельцем домена и не было изменено в пути.
|
||||
*
|
||||
* При старте генерирует RSA-ключ (2048 бит) и сохраняет его в файл.
|
||||
* Подписывает заголовки From, Subject, Date, To и тело сообщения.
|
||||
*/
|
||||
@Service
|
||||
public class DkimService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DkimService.class);
|
||||
|
||||
private static final String DKIM_SELECTOR = "default";
|
||||
|
||||
@Value("${mailserver.dkim.domain:localhost}")
|
||||
private String domain;
|
||||
|
||||
@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 String publicKeyBase64;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
|
||||
if (!enabled) {
|
||||
log.info("DKIM подписи отключены");
|
||||
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);
|
||||
} else {
|
||||
// Генерируем новые ключи
|
||||
KeyPair keyPair = generateKeyPair();
|
||||
privateKey = keyPair.getPrivate();
|
||||
publicKey = keyPair.getPublic();
|
||||
saveKeys(privateKeyFile, publicKeyFile, keyPair);
|
||||
log.info("DKIM: сгенерированы новые ключи для домена {}", domain);
|
||||
}
|
||||
|
||||
// Формируем публичный ключ в формате DNS TXT-записи
|
||||
publicKeyBase64 = encodePublicKeyForDns(publicKey);
|
||||
log.info("DKIM публичный ключ для DNS TXT-записи (_{}._domainkey.{}):", DKIM_SELECTOR, domain);
|
||||
log.info("v=DKIM1; k=rsa; p={}", publicKeyBase64);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Ошибка инициализации DKIM", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерирует RSA-ключ 2048 бит для DKIM.
|
||||
*/
|
||||
private KeyPair generateKeyPair() throws NoSuchAlgorithmException {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
return keyPairGenerator.generateKeyPair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет ключи в PEM-формат.
|
||||
*/
|
||||
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 {
|
||||
if (!enabled || privateKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// Вычисляем хэш тела
|
||||
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);
|
||||
// Не бросаем исключение — письмо будет отправлено без подписи
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует DKIM-подпись входящего сообщения.
|
||||
*
|
||||
* @param mimeMessage MIME-сообщение с DKIM-подписью
|
||||
* @return true если подпись валидна, false если подпись отсутствует или невалидна
|
||||
*/
|
||||
public boolean verifyMessage(MimeMessage mimeMessage) {
|
||||
if (!enabled) {
|
||||
return true; // Если DKIM отключён, считаем все письма валидными
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Верифицирует подпись локального домена.
|
||||
*/
|
||||
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,318 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.naming.Context;
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.Attributes;
|
||||
import javax.naming.directory.DirContext;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
import java.util.Hashtable;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Сервис для DMARC (Domain-based Message Authentication, Reporting & Conformance) проверки.
|
||||
*
|
||||
* DMARC объединяет SPF и DKIM, позволяя владельцу домена указать, что делать
|
||||
* с письмами, которые не прошли проверку SPF и/или DKIM.
|
||||
*
|
||||
* Политики DMARC:
|
||||
* - none: только мониторить, не предпринимать действий
|
||||
* - quarantine: отправлять в спам
|
||||
* - reject: отклонять письмо
|
||||
*
|
||||
* При получении письма DMARC проверяет:
|
||||
* 1. Прошло ли письмо SPF-проверку с alignment (домен в From совпадает с доменом SPF)
|
||||
* 2. Прошла ли письмо DKIM-проверку с alignment (домен в From совпадает с доменом DKIM)
|
||||
* 3. Если ни одна проверка не пройдена — применяется политика из DNS
|
||||
*/
|
||||
@Service
|
||||
public class DmarcService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DmarcService.class);
|
||||
|
||||
@Value("${mailserver.dmarc.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${mailserver.dmarc.local-bypass:true}")
|
||||
private boolean localBypass;
|
||||
|
||||
private final SpfService spfService;
|
||||
|
||||
public DmarcService(SpfService spfService) {
|
||||
this.spfService = spfService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат DMARC-проверки.
|
||||
*/
|
||||
public enum DmarcResult {
|
||||
PASS("DMARC проверка пройдена"),
|
||||
QUARANTINE("DMARC рекомендует отправить в спам"),
|
||||
REJECT("DMARC рекомендует отклонить письмо"),
|
||||
NONE("DMARC политика — только мониторинг"),
|
||||
ERROR("Ошибка при проверке DMARC");
|
||||
|
||||
private final String description;
|
||||
|
||||
DmarcResult(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Политика DMARC из DNS-записи.
|
||||
*/
|
||||
public enum DmarcPolicy {
|
||||
NONE("Только мониторинг"),
|
||||
QUARANTINE("Отправлять в спам"),
|
||||
REJECT("Отклонять");
|
||||
|
||||
private final String description;
|
||||
|
||||
DmarcPolicy(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет DMARC для входящего письма.
|
||||
*
|
||||
* @param fromEmail email из заголовка From (visible sender)
|
||||
* @param senderIp IP-адрес SMTP-клиента
|
||||
* @param spfPassed результат SPF-проверки
|
||||
* @param dkimPassed результат DKIM-проверки
|
||||
* @param dkimDomain домен из DKIM-подписи (если есть)
|
||||
* @return результат DMARC-проверки
|
||||
*/
|
||||
public DmarcResult checkDmarc(String fromEmail, String senderIp,
|
||||
boolean spfPassed, boolean dkimPassed,
|
||||
String dkimDomain) {
|
||||
if (!enabled) {
|
||||
log.debug("DMARC проверка отключена");
|
||||
return DmarcResult.PASS;
|
||||
}
|
||||
|
||||
if (fromEmail == null || fromEmail.isEmpty()) {
|
||||
log.warn("DMARC: email отправителя пуст");
|
||||
return DmarcResult.ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
// Извлекаем домен из заголовка From
|
||||
String fromDomain = extractDomain(fromEmail);
|
||||
if (fromDomain == null || fromDomain.isEmpty()) {
|
||||
log.warn("DMARC: не удалось извлечь домен из {}", fromEmail);
|
||||
return DmarcResult.ERROR;
|
||||
}
|
||||
|
||||
// Локальные домены пропускаем
|
||||
if (localBypass && isLocalDomain(fromDomain)) {
|
||||
log.debug("DMARC: локальный домен {} пропущен", fromDomain);
|
||||
return DmarcResult.PASS;
|
||||
}
|
||||
|
||||
// Проверяем SPF alignment
|
||||
boolean spfAligned = spfPassed;
|
||||
// SPF alignment: домен в From должен совпадать с доменом отправителя SMTP
|
||||
// Для локального сервера считаем, что alignment выполнен, если SPF прошёл
|
||||
|
||||
// Проверяем DKIM alignment
|
||||
boolean dkimAligned = false;
|
||||
if (dkimPassed && dkimDomain != null) {
|
||||
// DKIM alignment: домен в From должен совпадать с доменом DKIM
|
||||
dkimAligned = fromDomain.equalsIgnoreCase(dkimDomain)
|
||||
|| isSubdomain(dkimDomain, fromDomain)
|
||||
|| isSubdomain(fromDomain, dkimDomain);
|
||||
}
|
||||
|
||||
// Если хотя бы одна проверка пройдена с alignment — DMARC PASSED
|
||||
if (spfAligned || dkimAligned) {
|
||||
log.info("DMARC: проверка пройдена для {} (SPF: {}, DKIM: {})",
|
||||
fromDomain, spfAligned, dkimAligned);
|
||||
return DmarcResult.PASS;
|
||||
}
|
||||
|
||||
// Ни одна проверка не пройдена — получаем политику из DNS
|
||||
DmarcPolicy policy = getDmarcPolicy(fromDomain);
|
||||
if (policy == null) {
|
||||
// DMARC-запись не найдена — проверяем родительский домен
|
||||
String parentDomain = getParentDomain(fromDomain);
|
||||
if (parentDomain != null) {
|
||||
policy = getDmarcPolicy(parentDomain);
|
||||
}
|
||||
|
||||
if (policy == null) {
|
||||
// Нет DMARC-записи — письмо не обрабатывается DMARC
|
||||
log.info("DMARC: запись не найдена для {}", fromDomain);
|
||||
return DmarcResult.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("DMARC: политика {} для {} (SPF: {}, DKIM: {})",
|
||||
policy, fromDomain, spfPassed, dkimPassed);
|
||||
|
||||
// Применяем политику
|
||||
switch (policy) {
|
||||
case REJECT:
|
||||
return DmarcResult.REJECT;
|
||||
case QUARANTINE:
|
||||
return DmarcResult.QUARANTINE;
|
||||
case NONE:
|
||||
return DmarcResult.NONE;
|
||||
default:
|
||||
return DmarcResult.NONE;
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("DMARC: ошибка проверки для {}", fromEmail, e);
|
||||
return DmarcResult.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает домен из email-адреса.
|
||||
*/
|
||||
private String extractDomain(String email) {
|
||||
int atIndex = email.lastIndexOf('@');
|
||||
if (atIndex == -1 || atIndex == email.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
return email.substring(atIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли домен локальным.
|
||||
*/
|
||||
private boolean isLocalDomain(String domain) {
|
||||
return domain.equals("localhost")
|
||||
|| domain.endsWith(".localhost")
|
||||
|| domain.endsWith(".local")
|
||||
|| domain.endsWith(".internal");
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли domain поддоменом parent.
|
||||
*/
|
||||
private boolean isSubdomain(String domain, String parent) {
|
||||
if (domain.equals(parent)) return true;
|
||||
return domain.endsWith("." + parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает родительский домен.
|
||||
* Например, mail.example.com -> example.com
|
||||
*/
|
||||
private String getParentDomain(String domain) {
|
||||
int dotIndex = domain.indexOf('.');
|
||||
if (dotIndex == -1) return null;
|
||||
|
||||
int secondDotIndex = domain.indexOf('.', dotIndex + 1);
|
||||
if (secondDotIndex == -1) return null;
|
||||
|
||||
return domain.substring(dotIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает DMARC-политику из DNS.
|
||||
*/
|
||||
private DmarcPolicy getDmarcPolicy(String domain) {
|
||||
try {
|
||||
// DMARC-запись находится в _dmarc.<domain>
|
||||
String dmarcDomain = "_dmarc." + domain;
|
||||
|
||||
Hashtable<String, String> env = new Hashtable<>();
|
||||
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
|
||||
env.put(Context.PROVIDER_URL, "dns:");
|
||||
|
||||
DirContext ctx = new InitialDirContext(env);
|
||||
Attributes attrs = ctx.getAttributes(dmarcDomain, new String[]{"TXT"});
|
||||
Attribute txtAttr = attrs.get("TXT");
|
||||
|
||||
if (txtAttr == null || txtAttr.size() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String dmarcRecord = txtAttr.get().toString();
|
||||
// Убираем кавычки
|
||||
if (dmarcRecord.startsWith("\"") && dmarcRecord.endsWith("\"")) {
|
||||
dmarcRecord = dmarcRecord.substring(1, dmarcRecord.length() - 1);
|
||||
}
|
||||
|
||||
log.debug("DMARC: запись для {} — {}", domain, dmarcRecord);
|
||||
|
||||
// Парсим политику
|
||||
return parseDmarcPolicy(dmarcRecord);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("DMARC: ошибка DNS-запроса для {} — {}", domain, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит DMARC-политику из DNS-записи.
|
||||
*/
|
||||
private DmarcPolicy parseDmarcPolicy(String dmarcRecord) {
|
||||
// Проверяем версию
|
||||
if (!dmarcRecord.toLowerCase().startsWith("v=dmarc1")) {
|
||||
log.warn("DMARC: неверная версия записи");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Извлекаем политику
|
||||
String policyStr = extractValue(dmarcRecord, "p=");
|
||||
if (policyStr == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
policyStr = policyStr.toLowerCase();
|
||||
switch (policyStr) {
|
||||
case "reject":
|
||||
return DmarcPolicy.REJECT;
|
||||
case "quarantine":
|
||||
return DmarcPolicy.QUARANTINE;
|
||||
case "none":
|
||||
return DmarcPolicy.NONE;
|
||||
default:
|
||||
log.warn("DMARC: неизвестная политика {}", policyStr);
|
||||
return DmarcPolicy.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает значение параметра из DMARC-записи.
|
||||
*/
|
||||
private String extractValue(String record, String param) {
|
||||
int index = record.toLowerCase().indexOf(param.toLowerCase());
|
||||
if (index == -1) return null;
|
||||
|
||||
int start = index + param.length();
|
||||
int end = record.indexOf(';', start);
|
||||
if (end == -1) end = record.length();
|
||||
|
||||
return record.substring(start, end).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, рекомендует ли DMARC пометить письмо как спам.
|
||||
*/
|
||||
public boolean shouldMarkAsSpam(DmarcResult result) {
|
||||
return result == DmarcResult.QUARANTINE || result == DmarcResult.REJECT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, рекомендует ли DMARC отклонить письмо.
|
||||
*/
|
||||
public boolean shouldReject(DmarcResult result) {
|
||||
return result == DmarcResult.REJECT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.Attachment;
|
||||
import com.mailserver.domain.Folder;
|
||||
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.FolderRepository;
|
||||
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.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Instant;
|
||||
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 {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MailService.class);
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
private final FolderRepository folderRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final AttachmentRepository attachmentRepository;
|
||||
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,
|
||||
SpamFilterService spamFilterService,
|
||||
DkimService dkimService) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.folderRepository = folderRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.attachmentRepository = attachmentRepository;
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
String subject, String body, boolean isHtml) {
|
||||
return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, false);
|
||||
}
|
||||
|
||||
public Mono<Void> receiveMessage(String senderEmail, String senderName, String recipientEmail,
|
||||
String subject, String body, boolean isHtml, boolean externalSpamFlag) {
|
||||
return userRepository.findByEmail(recipientEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
|
||||
.flatMap(recipient ->
|
||||
folderRepository.findByUserIdAndName(recipient.getId(), "INBOX")
|
||||
.switchIfEmpty(Mono.error(new IllegalStateException("INBOX not found")))
|
||||
.flatMap(inbox ->
|
||||
spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
|
||||
.flatMap(isBlacklisted -> {
|
||||
boolean isSpam = isBlacklisted || externalSpamFlag;
|
||||
Message message = new Message();
|
||||
message.setFolderId(inbox.getId());
|
||||
message.setSenderEmail(senderEmail);
|
||||
message.setSenderName(senderName);
|
||||
message.setRecipientEmail(recipientEmail);
|
||||
message.setSubject(subject);
|
||||
message.setBody(body);
|
||||
message.setHtml(isHtml);
|
||||
message.setRead(false);
|
||||
message.setSpam(isSpam);
|
||||
message.setReceivedAt(Instant.now());
|
||||
message.setMessageId(UUID.randomUUID().toString());
|
||||
message.setMessageSize((long) (subject.length() + body.length()));
|
||||
return messageRepository.save(message)
|
||||
.then(folderRepository.save(inbox));
|
||||
})
|
||||
)
|
||||
)
|
||||
.then();
|
||||
}
|
||||
|
||||
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)
|
||||
.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)
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Flux<MessageResponse> searchMessagesWithFilters(Long folderId, Long userId,
|
||||
String subject, String body,
|
||||
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)
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<MessageResponse> getMessage(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.map(this::toResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> markAsRead(Long messageId, Long userId) {
|
||||
return messageRepository.findById(messageId)
|
||||
.flatMap(message -> {
|
||||
message.setRead(true);
|
||||
return messageRepository.save(message).then();
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<Void> deleteMessage(Long messageId, Long userId) {
|
||||
return messageRepository.deleteById(messageId);
|
||||
}
|
||||
|
||||
private MessageResponse toResponse(Message message) {
|
||||
MessageResponse resp = new MessageResponse();
|
||||
resp.setId(message.getId());
|
||||
resp.setSenderEmail(message.getSenderEmail());
|
||||
resp.setSenderName(message.getSenderName());
|
||||
resp.setRecipientEmail(message.getRecipientEmail());
|
||||
resp.setSubject(message.getSubject());
|
||||
resp.setBody(message.getBody());
|
||||
resp.setHtml(message.isHtml());
|
||||
resp.setRead(message.isRead());
|
||||
resp.setFlagged(message.isFlagged());
|
||||
resp.setSpam(message.isSpam());
|
||||
resp.setMessageSize(message.getMessageSize());
|
||||
resp.setReceivedAt(message.getReceivedAt());
|
||||
resp.setFolderId(message.getFolderId());
|
||||
return resp;
|
||||
}
|
||||
|
||||
public Mono<AttachmentResponse> saveAttachmentReactive(Long messageId, Long userId,
|
||||
String originalFilename, String contentType,
|
||||
long sizeBytes, String filePath) {
|
||||
return messageRepository.findById(messageId)
|
||||
.flatMap(message -> {
|
||||
Attachment attachment = new Attachment();
|
||||
attachment.setMessageId(messageId);
|
||||
attachment.setFilename(originalFilename);
|
||||
attachment.setContentType(contentType);
|
||||
attachment.setSizeBytes(sizeBytes);
|
||||
attachment.setFilePath(filePath);
|
||||
return attachmentRepository.save(attachment);
|
||||
})
|
||||
.map(this::toAttachmentResponse);
|
||||
}
|
||||
|
||||
public String getAttachmentsDir() {
|
||||
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();
|
||||
}
|
||||
|
||||
public Flux<AttachmentResponse> getMessageAttachments(Long messageId, Long userId) {
|
||||
return attachmentRepository.findByMessageId(messageId)
|
||||
.map(this::toAttachmentResponse);
|
||||
}
|
||||
|
||||
public Mono<Void> deleteAttachment(Long attachmentId, Long userId) {
|
||||
return attachmentRepository.findById(attachmentId)
|
||||
.flatMap(attachment -> {
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(attachment.getFilePath()));
|
||||
} catch (IOException e) {
|
||||
// Игнорируем
|
||||
}
|
||||
return attachmentRepository.delete(attachment);
|
||||
});
|
||||
}
|
||||
|
||||
private AttachmentResponse toAttachmentResponse(Attachment attachment) {
|
||||
AttachmentResponse resp = new AttachmentResponse();
|
||||
resp.setId(attachment.getId());
|
||||
resp.setFilename(attachment.getFilename());
|
||||
resp.setContentType(attachment.getContentType());
|
||||
resp.setSizeBytes(attachment.getSizeBytes());
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
import reactor.netty.Connection;
|
||||
import reactor.netty.tcp.TcpClient;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SMTP релей — доставляет письма на внешнюю почту.
|
||||
* Находит MX-запись домена получателя, подключается к его SMTP-серверу
|
||||
* и пересылает письмо через SMTP протокол.
|
||||
*/
|
||||
@Service
|
||||
public class SmtpRelayService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SmtpRelayService.class);
|
||||
|
||||
@Value("${mailserver.relay.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${mailserver.relay.port:587}")
|
||||
private int relayPort;
|
||||
|
||||
@Value("${mailserver.relay.starttls:true}")
|
||||
private boolean startTls;
|
||||
|
||||
@Value("${mailserver.relay.auth-enabled:false}")
|
||||
private boolean authEnabled;
|
||||
|
||||
@Value("${mailserver.relay.username:}")
|
||||
private String username;
|
||||
|
||||
@Value("${mailserver.relay.password:}")
|
||||
private String password;
|
||||
|
||||
@Value("${mailserver.smtp.hostname:localhost}")
|
||||
private String localHostname;
|
||||
|
||||
/**
|
||||
* Доставляет письмо на внешний адрес через SMTP релей.
|
||||
*/
|
||||
public Mono<Void> relayMessage(String from, String to, String mimeContent) {
|
||||
if (!enabled) {
|
||||
log.warn("SMTP релей отключён (mailserver.relay.enabled=false)");
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
String domain = extractDomain(to);
|
||||
if (domain == null) {
|
||||
return Mono.error(new RuntimeException("Invalid recipient: " + to));
|
||||
}
|
||||
|
||||
log.info("SMTP релей: доставка {} -> {} (домен: {})", from, to, domain);
|
||||
|
||||
return findMxServer(domain)
|
||||
.flatMap(mxHost -> {
|
||||
log.info("SMTP релей: MX-сервер для {}: {}", domain, mxHost);
|
||||
return sendToExternalServer(from, to, mimeContent, mxHost);
|
||||
})
|
||||
.doOnSuccess(v -> log.info("SMTP релей: письмо доставлено {}", to))
|
||||
.onErrorResume(e -> {
|
||||
log.error("SMTP релей: ошибка доставки {}: {}", to, e.getMessage());
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает домен из email адреса.
|
||||
*/
|
||||
private String extractDomain(String email) {
|
||||
int atIndex = email.lastIndexOf('@');
|
||||
if (atIndex == -1) return null;
|
||||
return email.substring(atIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит MX-сервер для домена через DNS.
|
||||
* Использует JNDI DNS для поиска MX-записи.
|
||||
*/
|
||||
private Mono<String> findMxServer(String domain) {
|
||||
return Mono.fromCallable(() -> {
|
||||
// Пытаемся найти MX через JNDI DNS
|
||||
try {
|
||||
javax.naming.directory.DirContext ictx = new javax.naming.directory.InitialDirContext();
|
||||
String searchBase = domain;
|
||||
String searchFilter = "(objectClass=*)";
|
||||
javax.naming.directory.SearchControls ctls = new javax.naming.directory.SearchControls();
|
||||
ctls.setSearchScope(javax.naming.directory.SearchControls.OBJECT_SCOPE);
|
||||
ctls.setReturningAttributes(new String[]{"mx"});
|
||||
|
||||
javax.naming.NamingEnumeration<? extends javax.naming.directory.SearchResult> answer =
|
||||
ictx.search(searchBase, searchFilter, ctls);
|
||||
|
||||
while (answer.hasMore()) {
|
||||
javax.naming.directory.SearchResult result = answer.next();
|
||||
javax.naming.directory.Attributes attrs = result.getAttributes();
|
||||
if (attrs != null) {
|
||||
javax.naming.NamingEnumeration<? extends javax.naming.directory.Attribute> attrEnum = attrs.getAll();
|
||||
while (attrEnum.hasMore()) {
|
||||
javax.naming.directory.Attribute attr = attrEnum.next();
|
||||
if ("mx".equalsIgnoreCase(attr.getID())) {
|
||||
javax.naming.NamingEnumeration<?> vals = attr.getAll();
|
||||
if (vals.hasMore()) {
|
||||
String mxVal = vals.next().toString();
|
||||
// MX формат: "10 mail.server.com."
|
||||
String[] parts = mxVal.split("\\s+");
|
||||
if (parts.length >= 2) {
|
||||
String mxHost = parts[1];
|
||||
if (mxHost.endsWith(".")) {
|
||||
mxHost = mxHost.substring(0, mxHost.length() - 1);
|
||||
}
|
||||
log.debug("MX-запись для {}: {} (приоритет: {})", domain, mxHost, parts[0]);
|
||||
return mxHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("MX-поиск для {}: {}", domain, e.getMessage());
|
||||
}
|
||||
|
||||
// Fallback — используем A-запись домена
|
||||
try {
|
||||
java.net.InetAddress[] addresses = java.net.InetAddress.getAllByName(domain);
|
||||
if (addresses != null && addresses.length > 0) {
|
||||
log.debug("DNS A-запись для {}: {}", domain, addresses[0].getHostAddress());
|
||||
return domain;
|
||||
}
|
||||
} catch (UnknownHostException e) {
|
||||
log.warn("DNS: не найден адрес для {}", domain);
|
||||
}
|
||||
|
||||
// Последний fallback — используем домен напрямую
|
||||
return domain;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет письмо на внешний SMTP-сервер через TcpClient.
|
||||
* Поддерживает STARTTLS и AUTH LOGIN/PLAIN.
|
||||
*/
|
||||
private Mono<Void> sendToExternalServer(String from, String to, String mimeContent, String mxHost) {
|
||||
TcpClient client = TcpClient.create()
|
||||
.host(mxHost)
|
||||
.port(relayPort)
|
||||
.option(io.netty.channel.ChannelOption.SO_TIMEOUT, 30000)
|
||||
.option(io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000);
|
||||
|
||||
// Подготавливаем тело DATA (escaping строк, начинающихся с '.')
|
||||
StringBuilder dataBody = new StringBuilder();
|
||||
String[] lines = mimeContent.split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
if (line.startsWith(".")) {
|
||||
dataBody.append(".").append(line);
|
||||
} else {
|
||||
dataBody.append(line);
|
||||
}
|
||||
dataBody.append("\r\n");
|
||||
}
|
||||
dataBody.append(".\r\n");
|
||||
|
||||
return client.connect()
|
||||
.flatMap(connection -> {
|
||||
Sinks.Many<String> responseSink = Sinks.many().replay().limit(1);
|
||||
|
||||
// Подписываемся на inbound ОДИН раз
|
||||
connection.inbound()
|
||||
.receive()
|
||||
.asString(StandardCharsets.UTF_8)
|
||||
.doOnNext(line -> {
|
||||
String trimmed = line.trim();
|
||||
log.debug("SMTP релей ответ от {}: {}", mxHost, trimmed);
|
||||
responseSink.tryEmitNext(trimmed);
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
// 1. Ожидаем banner 220
|
||||
Mono<Void> send = sendAndAwait(connection, "", responseSink, "220");
|
||||
|
||||
// 2. EHLO
|
||||
send = send.then(sendAndAwait(connection, "EHLO " + localHostname + "\r\n", responseSink, "250"));
|
||||
|
||||
// 3. STARTTLS (если включено)
|
||||
if (startTls) {
|
||||
send = send.then(sendAndAwait(connection, "STARTTLS\r\n", responseSink, "220"));
|
||||
send = send.then(Mono.fromRunnable(() -> {
|
||||
try {
|
||||
log.info("SMTP релей: переход к TLS с {}", mxHost);
|
||||
// После STARTTLS нужно переключить соединение на TLS
|
||||
// В Netty это делается через pipeline modification
|
||||
io.netty.handler.ssl.SslContext sslCtx = io.netty.handler.ssl.SslContextBuilder.forClient()
|
||||
.trustManager(createInsecureTrustManager())
|
||||
.build();
|
||||
javax.net.ssl.SSLEngine engine = sslCtx.newEngine(connection.channel().alloc(), mxHost, relayPort);
|
||||
connection.channel().pipeline().addFirst(
|
||||
new io.netty.handler.ssl.SslHandler(engine)
|
||||
);
|
||||
} catch (Exception e) {
|
||||
log.error("SMTP релей: ошибка STARTTLS: {}", e.getMessage());
|
||||
}
|
||||
}));
|
||||
// Повторяем EHLO после TLS
|
||||
send = send.then(sendAndAwait(connection, "EHLO " + localHostname + "\r\n", responseSink, "250"));
|
||||
}
|
||||
|
||||
// 4. AUTH (если включено)
|
||||
if (authEnabled && !username.isEmpty()) {
|
||||
send = send.then(authenticate(connection, responseSink));
|
||||
}
|
||||
|
||||
// 5. MAIL FROM
|
||||
send = send.then(sendAndAwait(connection, "MAIL FROM:<" + from + ">\r\n", responseSink, "250"));
|
||||
|
||||
// 6. RCPT TO
|
||||
send = send.then(sendAndAwait(connection, "RCPT TO:<" + to + ">\r\n", responseSink, "250"));
|
||||
|
||||
// 7. DATA
|
||||
send = send.then(sendAndAwait(connection, "DATA\r\n", responseSink, "354"));
|
||||
|
||||
// 8. Тело письма
|
||||
send = send.then(sendAndAwait(connection, dataBody.toString(), responseSink, "250"));
|
||||
|
||||
// 9. QUIT
|
||||
send = send.then(sendAndAwait(connection, "QUIT\r\n", responseSink, "221"));
|
||||
|
||||
return send.then(Mono.fromRunnable(() -> connection.dispose()));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AUTH LOGIN — аутентификация через Base64 логин/пароль.
|
||||
*/
|
||||
private Mono<Void> authenticate(Connection connection, Sinks.Many<String> responseSink) {
|
||||
log.info("SMTP релей: аутентификация как {}", username);
|
||||
|
||||
// AUTH LOGIN
|
||||
Mono<Void> send = sendAndAwait(connection, "AUTH LOGIN\r\n", responseSink, "334");
|
||||
|
||||
// Отправляем логин (Base64)
|
||||
String encodedUsername = Base64.getEncoder().encodeToString(username.getBytes(StandardCharsets.UTF_8));
|
||||
send = send.then(sendAndAwait(connection, encodedUsername + "\r\n", responseSink, "334"));
|
||||
|
||||
// Отправляем пароль (Base64)
|
||||
String encodedPassword = Base64.getEncoder().encodeToString(password.getBytes(StandardCharsets.UTF_8));
|
||||
send = send.then(sendAndAwait(connection, encodedPassword + "\r\n", responseSink, "235"));
|
||||
|
||||
log.info("SMTP релей: аутентификация успешна");
|
||||
return send;
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт TrustManager с доверием ко всем сертификатам (для разработки).
|
||||
* Для продакшена используйте правильный TrustManager.
|
||||
*/
|
||||
private javax.net.ssl.X509TrustManager createInsecureTrustManager() {
|
||||
return new javax.net.ssl.X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
public void checkClientTrusted(X509Certificate[] certs, String authType) {}
|
||||
public void checkServerTrusted(X509Certificate[] certs, String authType) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет SMTP команду и ждёт ответ с указанным кодом.
|
||||
*/
|
||||
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();
|
||||
|
||||
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 relay response timeout", e)));
|
||||
|
||||
return send.then(readResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.BlacklistEntry;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.repository.BlacklistRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Сервис для управления чёрным списком отправителей.
|
||||
*/
|
||||
@Service
|
||||
public class SpamFilterService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SpamFilterService.class);
|
||||
|
||||
private final BlacklistRepository blacklistRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public SpamFilterService(BlacklistRepository blacklistRepository, UserRepository userRepository) {
|
||||
this.blacklistRepository = blacklistRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
public Mono<Boolean> isBlacklisted(Long userId, String senderEmail) {
|
||||
return blacklistRepository.isBlacklisted(userId, senderEmail);
|
||||
}
|
||||
|
||||
public Mono<BlacklistEntry> addToBlacklist(String userEmail, String senderEmail, String reason) {
|
||||
return userRepository.findByEmail(userEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail)))
|
||||
.flatMap(user ->
|
||||
blacklistRepository.isBlacklisted(user.getId(), senderEmail)
|
||||
.flatMap(isBlacklisted -> {
|
||||
if (isBlacklisted) {
|
||||
return Mono.error(new IllegalArgumentException("Sender is already blacklisted: " + senderEmail));
|
||||
}
|
||||
BlacklistEntry entry = new BlacklistEntry();
|
||||
entry.setUserId(user.getId());
|
||||
entry.setSenderEmail(senderEmail.toLowerCase().trim());
|
||||
entry.setReason(reason);
|
||||
entry.setCreatedAt(Instant.now());
|
||||
return blacklistRepository.save(entry);
|
||||
})
|
||||
)
|
||||
.doOnSuccess(entry -> log.info("Отправитель {} добавлен в чёрный список пользователя {}", senderEmail, userEmail));
|
||||
}
|
||||
|
||||
public Mono<Void> removeFromBlacklist(String userEmail, String senderEmail) {
|
||||
return userRepository.findByEmail(userEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail)))
|
||||
.flatMap(user ->
|
||||
blacklistRepository.deleteByUserIdAndSenderEmail(user.getId(), senderEmail)
|
||||
.doOnSuccess(v -> log.info("Отправитель {} удалён из чёрного списка пользователя {}", senderEmail, userEmail))
|
||||
);
|
||||
}
|
||||
|
||||
public Flux<BlacklistEntry> getBlacklist(String userEmail) {
|
||||
return userRepository.findByEmail(userEmail)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + userEmail)))
|
||||
.flatMapMany(user -> blacklistRepository.findByUserId(user.getId()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.naming.Context;
|
||||
import javax.naming.directory.Attribute;
|
||||
import javax.naming.directory.Attributes;
|
||||
import javax.naming.directory.DirContext;
|
||||
import javax.naming.directory.InitialDirContext;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Hashtable;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.net.InetAddress;
|
||||
|
||||
/**
|
||||
* Сервис для SPF (Sender Policy Framework) проверки отправителей.
|
||||
*
|
||||
* SPF позволяет владельцу домена указать, какие серверы имеют право
|
||||
* отправлять письма от имени этого домена. При получении письма
|
||||
* проверяющий сервер смотрит DNS-запись домена отправителя и сравнивает
|
||||
* IP-адрес отправителя с разрешёнными в SPF-записи.
|
||||
*
|
||||
* Результаты проверки:
|
||||
* - PASS: IP-адрес отправителя разрешён SPF-записью
|
||||
* - FAIL: IP-адрес не разрешён
|
||||
* - SOFTFAIL: IP-адрес, вероятно, не разрешён (-all vs ~all)
|
||||
* - NEUTRAL: домен имеет нейтральную SPF-запись
|
||||
* - NONE: SPF-запись не найдена
|
||||
* - ERROR: ошибка при проверке (DNS-сбой и т.д.)
|
||||
*/
|
||||
@Service
|
||||
public class SpfService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SpfService.class);
|
||||
|
||||
@Value("${mailserver.spf.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${mailserver.spf.local-bypass:true}")
|
||||
private boolean localBypass;
|
||||
|
||||
/**
|
||||
* Результат SPF-проверки.
|
||||
*/
|
||||
public enum SpfResult {
|
||||
PASS("Проверка пройдена — IP-адрес разрешён"),
|
||||
FAIL("Проверка не пройдена — IP-адрес не разрешён"),
|
||||
SOFTFAIL("Возможная подделка — IP-адрес, вероятно, не разрешён"),
|
||||
NEUTRAL("Нейтральная политика — домен не запрещает отправку"),
|
||||
NONE("SPF-запись не найдена для домена"),
|
||||
ERROR("Ошибка при проверке SPF");
|
||||
|
||||
private final String description;
|
||||
|
||||
SpfResult(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет SPF для письма от указанного отправителя с указанного IP-адреса.
|
||||
*
|
||||
* @param senderEmail email отправителя
|
||||
* @param senderIp IP-адрес SMTP-клиента (для локального сервера — "127.0.0.1")
|
||||
* @return результат SPF-проверки
|
||||
*/
|
||||
public SpfResult checkSpf(String senderEmail, String senderIp) {
|
||||
if (!enabled) {
|
||||
log.debug("SPF проверка отключена");
|
||||
return SpfResult.PASS;
|
||||
}
|
||||
|
||||
if (senderEmail == null || senderEmail.isEmpty()) {
|
||||
log.warn("SPF: email отправителя пуст");
|
||||
return SpfResult.ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
// Извлекаем домен из email
|
||||
String domain = extractDomain(senderEmail);
|
||||
if (domain == null || domain.isEmpty()) {
|
||||
log.warn("SPF: не удалось извлечь домен из {}", senderEmail);
|
||||
return SpfResult.ERROR;
|
||||
}
|
||||
|
||||
// Локальные адреса всегда проходят проверку
|
||||
if (localBypass && isLocalAddress(senderIp)) {
|
||||
log.debug("SPF: локальный адрес {} пропущен для домена {}", senderIp, domain);
|
||||
return SpfResult.PASS;
|
||||
}
|
||||
|
||||
// Получаем SPF-запись из DNS
|
||||
String spfRecord = getSpfRecord(domain);
|
||||
if (spfRecord == null || spfRecord.isEmpty()) {
|
||||
log.info("SPF: запись не найдена для домена {}", domain);
|
||||
return SpfResult.NONE;
|
||||
}
|
||||
|
||||
log.debug("SPF: запись для {} — {}", domain, spfRecord);
|
||||
|
||||
// Парсим и проверяем SPF-запись
|
||||
return evaluateSpfRecord(spfRecord, senderIp, domain);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("SPF: ошибка проверки для {} с IP {}", senderEmail, senderIp, e);
|
||||
return SpfResult.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает домен из email-адреса.
|
||||
*/
|
||||
private String extractDomain(String email) {
|
||||
int atIndex = email.lastIndexOf('@');
|
||||
if (atIndex == -1 || atIndex == email.length() - 1) {
|
||||
return null;
|
||||
}
|
||||
return email.substring(atIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли IP-адрес локальным.
|
||||
*/
|
||||
private boolean isLocalAddress(String ip) {
|
||||
if (ip == null) return false;
|
||||
try {
|
||||
InetAddress address = InetAddress.getByName(ip);
|
||||
return address.isLoopbackAddress()
|
||||
|| address.isAnyLocalAddress()
|
||||
|| address.isLinkLocalAddress()
|
||||
|| address.isSiteLocalAddress();
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает SPF-запись из DNS.
|
||||
* Сначала пытается получить TXT-запись домена, затем проверяет MX-запись.
|
||||
*/
|
||||
private String getSpfRecord(String domain) {
|
||||
// Пробуем получить TXT-запись
|
||||
String txtRecord = getTxtRecord(domain);
|
||||
if (txtRecord != null && txtRecord.toLowerCase().startsWith("v=spf1")) {
|
||||
return txtRecord;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает TXT-запись из DNS.
|
||||
*/
|
||||
private String getTxtRecord(String domain) {
|
||||
try {
|
||||
Hashtable<String, String> env = new Hashtable<>();
|
||||
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
|
||||
env.put(Context.PROVIDER_URL, "dns:");
|
||||
|
||||
DirContext ctx = new InitialDirContext(env);
|
||||
Attributes attrs = ctx.getAttributes(domain, new String[]{"TXT"});
|
||||
Attribute txtAttr = attrs.get("TXT");
|
||||
|
||||
if (txtAttr != null && txtAttr.size() > 0) {
|
||||
String txtValue = txtAttr.get().toString();
|
||||
// Убираем кавычки, если есть
|
||||
if (txtValue.startsWith("\"") && txtValue.endsWith("\"")) {
|
||||
txtValue = txtValue.substring(1, txtValue.length() - 1);
|
||||
}
|
||||
return txtValue;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("SPF: ошибка DNS-запроса для {} — {}", domain, e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Оценивает SPF-запись для указанного IP-адреса.
|
||||
*/
|
||||
private SpfResult evaluateSpfRecord(String spfRecord, String senderIp, String domain) {
|
||||
// Убираем версию
|
||||
String record = spfRecord.replaceAll("^v=spf1\\s+", "");
|
||||
|
||||
// Разбираем механизмы SPF
|
||||
String[] mechanisms = tokenizeSpf(record);
|
||||
|
||||
// Проверяем каждый механизм
|
||||
for (String mechanism : mechanisms) {
|
||||
mechanism = mechanism.trim();
|
||||
if (mechanism.isEmpty()) continue;
|
||||
|
||||
SpfResult result = evaluateMechanism(mechanism, senderIp, domain);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Если ни один механизм не совпал, проверяем модификатор all
|
||||
// По умолчанию — FAIL
|
||||
return SpfResult.FAIL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбивает SPF-запись на токены.
|
||||
*/
|
||||
private String[] tokenizeSpf(String record) {
|
||||
// Разделяем по пробелам, но сохраняем модификаторы
|
||||
return record.split("\\s+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Оценивает один механизм SPF.
|
||||
*/
|
||||
private SpfResult evaluateMechanism(String mechanism, String senderIp, String domain) {
|
||||
// Определяем тип совпадения
|
||||
char qualifier = '+'; // по умолчанию — PASS
|
||||
String mech = mechanism;
|
||||
|
||||
if (mechanism.startsWith("+")) {
|
||||
qualifier = '+';
|
||||
mech = mechanism.substring(1);
|
||||
} else if (mechanism.startsWith("-")) {
|
||||
qualifier = '-';
|
||||
mech = mechanism.substring(1);
|
||||
} else if (mechanism.startsWith("~")) {
|
||||
qualifier = '~';
|
||||
mech = mechanism.substring(1);
|
||||
} else if (mechanism.startsWith("?")) {
|
||||
qualifier = '?';
|
||||
mech = mechanism.substring(1);
|
||||
}
|
||||
|
||||
// Проверяем механизм
|
||||
boolean match = false;
|
||||
|
||||
if (mech.equals("all")) {
|
||||
match = true;
|
||||
} else if (mech.equals("ip4") || mech.startsWith("ip4:") || mech.equals("ip6") || mech.startsWith("ip6:")) {
|
||||
// IP-механизмы игнорируем для локального сервера
|
||||
// В продакшене здесь была бы проверка IP-адреса
|
||||
match = false;
|
||||
} else if (mech.equals("mx")) {
|
||||
// Проверяем, есть ли MX-запись для домена
|
||||
match = hasMxRecord(domain);
|
||||
} else if (mech.equals("a")) {
|
||||
// Проверяем, есть ли A-запись для домена
|
||||
match = hasARecord(domain);
|
||||
} else if (mech.startsWith("include:")) {
|
||||
// Включаем SPF другого домена
|
||||
String includedDomain = mech.substring("include:".length());
|
||||
String includedSpf = getSpfRecord(includedDomain);
|
||||
if (includedSpf != null) {
|
||||
SpfResult includedResult = evaluateSpfRecord(includedSpf, senderIp, includedDomain);
|
||||
if (includedResult == SpfResult.PASS) {
|
||||
match = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
return null; // Продолжаем проверять следующие механизмы
|
||||
}
|
||||
|
||||
// Возвращаем результат в зависимости от квалификатора
|
||||
switch (qualifier) {
|
||||
case '+':
|
||||
return SpfResult.PASS;
|
||||
case '-':
|
||||
return SpfResult.FAIL;
|
||||
case '~':
|
||||
return SpfResult.SOFTFAIL;
|
||||
case '?':
|
||||
return SpfResult.NEUTRAL;
|
||||
default:
|
||||
return SpfResult.PASS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие MX-записи для домена.
|
||||
*/
|
||||
private boolean hasMxRecord(String domain) {
|
||||
try {
|
||||
Hashtable<String, String> env = new Hashtable<>();
|
||||
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
|
||||
env.put(Context.PROVIDER_URL, "dns:");
|
||||
|
||||
DirContext ctx = new InitialDirContext(env);
|
||||
Attributes attrs = ctx.getAttributes(domain, new String[]{"MX"});
|
||||
Attribute mxAttr = attrs.get("MX");
|
||||
|
||||
return mxAttr != null && mxAttr.size() > 0;
|
||||
} catch (Exception e) {
|
||||
log.debug("SPF: ошибка проверки MX для {}", domain);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие A-записи для домена.
|
||||
*/
|
||||
private boolean hasARecord(String domain) {
|
||||
try {
|
||||
Hashtable<String, String> env = new Hashtable<>();
|
||||
env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory");
|
||||
env.put(Context.PROVIDER_URL, "dns:");
|
||||
|
||||
DirContext ctx = new InitialDirContext(env);
|
||||
Attributes attrs = ctx.getAttributes(domain, new String[]{"A"});
|
||||
Attribute aAttr = attrs.get("A");
|
||||
|
||||
return aAttr != null && aAttr.size() > 0;
|
||||
} catch (Exception e) {
|
||||
log.debug("SPF: ошибка проверки A для {}", domain);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли результат SPF-проверки провалом.
|
||||
*/
|
||||
public boolean isSpfFail(SpfResult result) {
|
||||
return result == SpfResult.FAIL || result == SpfResult.SOFTFAIL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.mailserver.service;
|
||||
|
||||
import com.mailserver.domain.Folder;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.FolderResponse;
|
||||
import com.mailserver.repository.FolderRepository;
|
||||
import com.mailserver.repository.UserRepository;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final FolderRepository folderRepository;
|
||||
private final BCryptPasswordEncoder passwordEncoder;
|
||||
|
||||
public UserService(UserRepository userRepository, FolderRepository folderRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.folderRepository = folderRepository;
|
||||
this.passwordEncoder = new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
public Mono<User> register(String email, String password, String displayName) {
|
||||
return userRepository.existsByEmail(email)
|
||||
.flatMap(exists -> {
|
||||
if (exists) {
|
||||
return Mono.error(new IllegalArgumentException("Email already registered: " + email));
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setEmail(email);
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
user.setDisplayName(displayName);
|
||||
user.setCreatedAt(Instant.now());
|
||||
return userRepository.save(user);
|
||||
})
|
||||
.flatMap(user -> {
|
||||
// Создаём стандартные папки
|
||||
String[] defaultFolders = {"INBOX", "Sent", "Drafts", "Trash", "Spam"};
|
||||
return Flux.fromArray(defaultFolders)
|
||||
.flatMap(name -> {
|
||||
Folder folder = new Folder();
|
||||
folder.setUserId(user.getId());
|
||||
folder.setName(name);
|
||||
folder.setCreatedAt(Instant.now());
|
||||
return folderRepository.save(folder);
|
||||
})
|
||||
.then(Mono.just(user));
|
||||
});
|
||||
}
|
||||
|
||||
public Mono<User> findByEmail(String email) {
|
||||
return userRepository.findByEmail(email)
|
||||
.switchIfEmpty(Mono.error(new IllegalArgumentException("User not found: " + email)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, совпадает ли пароль с хэшем, хранящимся у пользователя.
|
||||
*/
|
||||
public boolean checkPassword(User user, String rawPassword) {
|
||||
return passwordEncoder.matches(rawPassword, user.getPassword());
|
||||
}
|
||||
|
||||
public Flux<FolderResponse> getFolders(Long userId) {
|
||||
return folderRepository.findByUserId(userId)
|
||||
.map(f -> {
|
||||
FolderResponse resp = new FolderResponse();
|
||||
resp.setId(f.getId());
|
||||
resp.setName(f.getName());
|
||||
resp.setMessageCount(f.getMessageCount());
|
||||
resp.setUnreadCount(f.getUnreadCount());
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user