Initial commit: mail-server with WebFlux backend and Angular v22 Synthwave frontend

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
2026-11-05 01:07:29 +01:00
co-authored by Junie
commit 0339413d6e
78 changed files with 6808 additions and 0 deletions
@@ -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;
});
}
}