feat(notification): add email sending

This commit is contained in:
ulitsaRaskolnikova 2026-01-09 19:30:06 +03:00
parent e2f41ebc9e
commit 1ae10a3f85
18 changed files with 366 additions and 12 deletions

View File

@ -1,5 +1,6 @@
package ru.itmo.common.notification;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@ -13,8 +14,12 @@ import java.util.UUID;
@NoArgsConstructor
@AllArgsConstructor
public class SendNotificationRequest {
@NotNull(message = "User ID is required")
private UUID userId;
@NotNull(message = "Notification type is required")
private NotificationType type;
private String subject;
private String template;
private Map<String, Object> parameters;

View File

@ -61,6 +61,10 @@ services:
- "8085:8085"
environment:
- SPRING_PROFILES_ACTIVE=docker
- MAIL_HOST=${MAIL_HOST:-smtp.yandex.ru}
- MAIL_PORT=${MAIL_PORT:-587}
- MAIL_USERNAME=${MAIL_USERNAME:-}
- MAIL_PASSWORD=${MAIL_PASSWORD:-}
depends_on:
- postgres-notification
networks:

View File

@ -8,9 +8,7 @@ COPY common/build.gradle.kts ./common/
COPY domain-order-service/src ./domain-order-service/src
COPY common/src ./common/src
RUN gradle :domain-order-service:bootJar --no-daemon \
--refresh-dependencies \
-Dorg.gradle.jvmargs="-Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Dhttps.protocols=TLSv1.2,TLSv1.3"
RUN gradle :domain-order-service:bootJar --no-daemon
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app

View File

@ -15,6 +15,8 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.liquibase:liquibase-core")
implementation("com.zaxxer:HikariCP")
implementation(project(":common"))

View File

@ -0,0 +1,14 @@
package ru.itmo.notification.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@ -1,27 +1,25 @@
package ru.itmo.notification.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import ru.itmo.common.dto.ApiResponse;
import ru.itmo.common.notification.SendNotificationRequest;
import java.util.UUID;
import ru.itmo.notification.service.NotificationService;
@RestController
@RequestMapping("/notifications")
@RequiredArgsConstructor
public class NotificationController {
private final NotificationService notificationService;
@PostMapping("/send")
public ResponseEntity<ApiResponse<Void>> sendNotification(
@RequestBody SendNotificationRequest request) {
// TODO: Implement notification sending logic
// - Validate request
// - Get user email from Auth Service
// - Send email via SMTP/Email API
// - Log notification status
@Valid @RequestBody SendNotificationRequest request) {
notificationService.sendNotification(request);
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(ApiResponse.success(null));
}

View File

@ -0,0 +1,37 @@
package ru.itmo.notification.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import ru.itmo.common.dto.ApiError;
import ru.itmo.common.dto.ApiResponse;
import ru.itmo.notification.service.EmailSendingException;
@RestControllerAdvice
@Slf4j
public class EmailSendingExceptionHandler {
@ExceptionHandler(EmailSendingException.class)
public ResponseEntity<ApiResponse<Void>> handleEmailSendingException(EmailSendingException e) {
log.error("Email sending failed", e);
ApiError error = new ApiError(
"EMAIL_SENDING_FAILED",
"Failed to send email: " + e.getMessage()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(error));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgumentException(IllegalArgumentException e) {
log.error("Invalid request", e);
ApiError error = new ApiError(
"INVALID_REQUEST",
e.getMessage()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(error));
}
}

View File

@ -0,0 +1,11 @@
package ru.itmo.notification.service;
public class EmailSendingException extends RuntimeException {
public EmailSendingException(String message) {
super(message);
}
public EmailSendingException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,69 @@
package ru.itmo.notification.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import ru.itmo.common.notification.NotificationType;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailService {
private final JavaMailSender mailSender;
private final EmailTemplateService emailTemplateService;
@Value("${spring.mail.from:noreply@hrofors.ru}")
private String mailFrom;
public void sendEmail(String to, String subject, String text) {
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(to);
message.setSubject(subject);
message.setText(text);
message.setFrom(mailFrom);
mailSender.send(message);
log.info("Email sent successfully to: {}", to);
} catch (Exception e) {
log.error("Failed to send email to: {}", to, e);
throw new EmailSendingException("Failed to send email", e);
}
}
public void sendHtmlEmail(String to, String subject, String htmlContent) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true);
helper.setFrom(mailFrom);
mailSender.send(message);
log.info("HTML email sent successfully to: {}", to);
} catch (MessagingException e) {
log.error("Failed to send HTML email to: {}", to, e);
throw new EmailSendingException("Failed to send HTML email", e);
}
}
public void sendNotification(String to, NotificationType type, String subject, Map<String, Object> parameters) {
try {
String templateName = emailTemplateService.getTemplateName(type);
String htmlContent = emailTemplateService.processTemplate(templateName, parameters);
sendHtmlEmail(to, subject, htmlContent);
log.info("Notification email sent: type={}, to={}", type, to);
} catch (Exception e) {
log.error("Failed to send notification email: type={}, to={}", type, to, e);
throw new EmailSendingException("Failed to send notification email", e);
}
}
}

View File

@ -0,0 +1,34 @@
package ru.itmo.notification.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import ru.itmo.common.notification.NotificationType;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class EmailTemplateService {
private final TemplateEngine templateEngine;
public String getTemplateName(NotificationType type) {
return switch (type) {
case ORDER_CREATED -> "order-created";
case PAYMENT_APPROVED -> "payment-approved";
case DOMAIN_ACTIVATED -> "domain-activated";
case DOMAIN_EXPIRING_SOON -> "domain-expiring-soon";
case DOMAIN_EXPIRED -> "domain-expired";
};
}
public String processTemplate(String templateName, Map<String, Object> parameters) {
Context context = new Context();
if (parameters != null) {
parameters.forEach(context::setVariable);
}
return templateEngine.process("emails/" + templateName, context);
}
}

View File

@ -0,0 +1,63 @@
package ru.itmo.notification.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import ru.itmo.common.notification.NotificationType;
import ru.itmo.common.notification.SendNotificationRequest;
import java.util.UUID;
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationService {
private final EmailService emailService;
private final RestTemplate restTemplate;
private final String authServiceUrl = "http://auth-service:8081";
public void sendNotification(SendNotificationRequest request) {
// String userEmail = getUserEmail(request.getUserId());
String userEmail = "malyshev.2005n@gmail.com";
if (userEmail == null || userEmail.isEmpty()) {
log.warn("User email not found for userId: {}", request.getUserId());
throw new IllegalArgumentException("User email not found");
}
String subject = request.getSubject() != null
? request.getSubject()
: getDefaultSubject(request.getType());
emailService.sendNotification(
userEmail,
request.getType(),
subject,
request.getParameters()
);
}
private String getUserEmail(UUID userId) {
try {
String url = authServiceUrl + "/api/auth/users/" + userId + "/email";
String email = restTemplate.getForObject(url, String.class);
log.debug("Retrieved email for userId {}: {}", userId, email);
return email;
} catch (Exception e) {
log.error("Failed to get user email for userId: {}", userId, e);
return null;
}
}
private String getDefaultSubject(NotificationType type) {
return switch (type) {
case ORDER_CREATED -> "Заказ создан";
case PAYMENT_APPROVED -> "Платеж успешно обработан";
case DOMAIN_ACTIVATED -> "Домен активирован";
case DOMAIN_EXPIRING_SOON -> "Напоминание: срок действия домена истекает";
case DOMAIN_EXPIRED -> "Срок действия домена истек";
};
}
}

View File

@ -2,4 +2,19 @@ spring:
datasource:
url: jdbc:postgresql://postgres-notification:5432/notification_db
username: postgres
password: postgres
password: postgres
mail:
host: ${MAIL_HOST:smtp.gmail.com}
port: ${MAIL_PORT:587}
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
from: ${MAIL_FROM:noreply@hrofors.ru}
properties:
mail:
smtp:
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000

View File

@ -19,6 +19,27 @@ spring:
leak-detection-threshold: 60000
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
mail:
host: postbox.cloud.yandex.net
port: 587
username: ${MAIL_USERNAME:}
password: ${MAIL_PASSWORD:}
from: ${MAIL_FROM:noreply@hrofors.ru}
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
thymeleaf:
prefix: classpath:/templates/
suffix: .html
mode: HTML
encoding: UTF-8
jpa:
hibernate:
ddl-auto: none

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Домен активирован</title>
</head>
<body>
<h2>Домен успешно активирован</h2>
<p>Здравствуйте!</p>
<p>Ваш домен был успешно активирован и готов к использованию.</p>
<p th:if="${domainName}">Домен: <strong th:text="${domainName}"></strong></p>
<p th:if="${expiresAt}">Срок действия до: <strong th:text="${expiresAt}"></strong></p>
<p>Теперь вы можете управлять DNS-записями домена в личном кабинете.</p>
<p>С уважением,<br>Команда Domain Registrar</p>
</body>
</html>

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Срок действия домена истек</title>
</head>
<body>
<h2>Срок действия домена истек</h2>
<p>Здравствуйте!</p>
<p>Срок действия вашего домена истек.</p>
<p th:if="${domainName}">Домен: <strong th:text="${domainName}"></strong></p>
<p th:if="${expiresAt}">Срок действия истек: <strong th:text="${expiresAt}"></strong></p>
<p>Домен был деактивирован. Для восстановления доступа необходимо продлить регистрацию домена.</p>
<p>Продлите домен в личном кабинете как можно скорее.</p>
<p>С уважением,<br>Команда Domain Registrar</p>
</body>
</html>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Напоминание: срок действия домена истекает</title>
</head>
<body>
<h2>Напоминание о продлении домена</h2>
<p>Здравствуйте!</p>
<p>Срок действия вашего домена скоро истечет.</p>
<p th:if="${domainName}">Домен: <strong th:text="${domainName}"></strong></p>
<p th:if="${expiresAt}">Срок действия до: <strong th:text="${expiresAt}"></strong></p>
<p th:if="${daysLeft}">Осталось дней: <strong th:text="${daysLeft}"></strong></p>
<p>Чтобы продолжить использование домена, необходимо продлить его регистрацию.</p>
<p>Продлите домен в личном кабинете, чтобы избежать его деактивации.</p>
<p>С уважением,<br>Команда Domain Registrar</p>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Заказ создан</title>
</head>
<body>
<h2>Заказ успешно создан</h2>
<p>Здравствуйте!</p>
<p>Ваш заказ был успешно создан.</p>
<p th:if="${orderId}">Номер заказа: <strong th:text="${orderId}"></strong></p>
<p th:if="${totalAmount}">Сумма заказа: <strong th:text="${totalAmount}"></strong> руб.</p>
<p>Для оплаты заказа перейдите по ссылке в личном кабинете.</p>
<p>С уважением,<br>Команда Domain Registrar</p>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Платеж успешно обработан</title>
</head>
<body>
<h2>Платеж успешно обработан</h2>
<p>Здравствуйте!</p>
<p>Ваш платеж был успешно обработан.</p>
<p th:if="${orderId}">Номер заказа: <strong th:text="${orderId}"></strong></p>
<p th:if="${amount}">Сумма платежа: <strong th:text="${amount}"></strong> руб.</p>
<p th:if="${domainName}">Домен <strong th:text="${domainName}"></strong> будет активирован в ближайшее время.</p>
<p>С уважением,<br>Команда Domain Registrar</p>
</body>
</html>