diff --git a/src/main/java/com/wellmeet/config/EmailConfig.java b/src/main/java/com/wellmeet/config/EmailConfig.java new file mode 100644 index 0000000..cc56ec4 --- /dev/null +++ b/src/main/java/com/wellmeet/config/EmailConfig.java @@ -0,0 +1,17 @@ +package com.wellmeet.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +@Profile("!test") +public class EmailConfig { + + @Bean + public JavaMailSender javaMailSender() { + return new JavaMailSenderImpl(); + } +} diff --git a/src/main/java/com/wellmeet/exception/ErrorCode.java b/src/main/java/com/wellmeet/exception/ErrorCode.java index 924fdca..05d7adc 100644 --- a/src/main/java/com/wellmeet/exception/ErrorCode.java +++ b/src/main/java/com/wellmeet/exception/ErrorCode.java @@ -6,6 +6,7 @@ @Getter public enum ErrorCode { + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "이메일 정보를 찾을 수 없습니다."), SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "구독 정보를 찾을 수 없습니다."), FIELD_ERROR(HttpStatus.BAD_REQUEST, "입력이 잘못되었습니다."), @@ -16,6 +17,7 @@ public enum ErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), CORS_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, "CORS Origin 은 적어도 한 개 있어야 합니다"), WEB_PUSH_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "웹 푸시 전송에 실패했습니다."), + EMAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송에 실패했습니다."), SENDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "알림을 발송할 수 없습니다."), TEMPLATE_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "알림 템플릿을 찾을 수 없습니다."); diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java index e6a226f..8876498 100644 --- a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationInfo.java @@ -9,4 +9,9 @@ public class NotificationInfo { private NotificationType type; private String recipient; + + public NotificationInfo(NotificationType type, String recipient) { + this.type = type; + this.recipient = recipient; + } } diff --git a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java index e8086cc..3ae7607 100644 --- a/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java +++ b/src/main/java/com/wellmeet/notification/consumer/dto/NotificationMessage.java @@ -12,6 +12,12 @@ public class NotificationMessage { private NotificationInfo notification; private Map payload; + public NotificationMessage(MessageHeader header, NotificationInfo notification, Map payload) { + this.header = header; + this.notification = notification; + this.payload = payload; + } + public String getRecipient() { return notification.getRecipient(); } diff --git a/src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java b/src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java new file mode 100644 index 0000000..d207f50 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/domain/EmailSubscription.java @@ -0,0 +1,34 @@ +package com.wellmeet.notification.email.domain; + +import com.wellmeet.common.domain.BaseEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EmailSubscription extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + private String userId; + + @NotNull + @Email + private String email; + + public EmailSubscription(String userId, String email) { + this.userId = userId; + this.email = email; + } +} diff --git a/src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java b/src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java new file mode 100644 index 0000000..e0700dd --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/repository/EmailSubscriptionRepository.java @@ -0,0 +1,10 @@ +package com.wellmeet.notification.email.repository; + +import com.wellmeet.notification.email.domain.EmailSubscription; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EmailSubscriptionRepository extends JpaRepository { + + Optional findByUserId(String userId); +} diff --git a/src/main/java/com/wellmeet/notification/email/sender/EmailSender.java b/src/main/java/com/wellmeet/notification/email/sender/EmailSender.java new file mode 100644 index 0000000..fd326de --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/sender/EmailSender.java @@ -0,0 +1,58 @@ +package com.wellmeet.notification.email.sender; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.Sender; +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.domain.NotificationChannel; +import com.wellmeet.notification.email.domain.EmailSubscription; +import com.wellmeet.notification.email.repository.EmailSubscriptionRepository; +import com.wellmeet.notification.template.NotificationTemplateData; +import com.wellmeet.notification.template.NotificationTemplateFactory; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailSender implements Sender { + + private final EmailSubscriptionRepository emailSubscriptionRepository; + private final NotificationTemplateFactory templateFactory; + private final MailViewRenderer mailViewRenderer; + private final MailTransport mailTransport; + + @Override + public boolean isEnabled(NotificationChannel channel) { + return NotificationChannel.EMAIL == channel; + } + + @Override + public void send(NotificationMessage message) { + String userId = message.getNotification().getRecipient(); + EmailSubscription subscription = emailSubscriptionRepository.findByUserId(userId) + .orElseThrow(() -> new WellMeetNotificationException(ErrorCode.EMAIL_NOT_FOUND)); + + NotificationTemplateData templateData = templateFactory.createTemplateData( + message.getNotification().getType(), + message.getPayload() + ); + + String subject = templateData.title(); + String htmlContent = buildHtmlContent(templateData); + sendEmail(subscription.getEmail(), subject, htmlContent); + } + + private String buildHtmlContent(NotificationTemplateData templateData) { + Map variables = Map.of( + "title", templateData.title(), + "body", templateData.body(), + "url", templateData.url() + ); + return mailViewRenderer.render("email-notification.html", variables); + } + + private void sendEmail(String to, String subject, String htmlContent) { + mailTransport.send(to, subject, htmlContent); + } +} diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java b/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java new file mode 100644 index 0000000..3a832e4 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/sender/MailTransport.java @@ -0,0 +1,34 @@ +package com.wellmeet.notification.email.sender; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MailTransport { + + private final JavaMailSender javaMailSender; + + public void send(String to, String subject, String htmlContent) { + try { + MimeMessage mimeMessage = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); + + javaMailSender.send(mimeMessage); + } catch (Exception e) { + log.error("이메일 전송에 실패했습니다. to: {}, subject: {}", to, subject, e); + throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); + } + } +} diff --git a/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java new file mode 100644 index 0000000..60589a8 --- /dev/null +++ b/src/main/java/com/wellmeet/notification/email/sender/MailViewRenderer.java @@ -0,0 +1,51 @@ +package com.wellmeet.notification.email.sender; + +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.util.StreamUtils; + +@Slf4j +@Component +public class MailViewRenderer { + + private static final String TEMPLATE_BASE_PATH = "templates/"; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{([^}]+)\\}"); + + public String render(String templateName, Map variables) { + String template = loadTemplate(templateName); + return replacePlaceholders(template, variables); + } + + private String loadTemplate(String templateName) { + ClassPathResource resource = new ClassPathResource(TEMPLATE_BASE_PATH + templateName); + try (InputStream inputStream = resource.getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + log.error("이메일 템플릿 로딩에 실패했습니다. templateName: {}", templateName, e); + throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); + } + } + + private String replacePlaceholders(String template, Map variables) { + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + StringBuilder result = new StringBuilder(); + + while (matcher.find()) { + String key = matcher.group(1); + String value = variables.getOrDefault(key, matcher.group(0)); + matcher.appendReplacement(result, Matcher.quoteReplacement(value)); + } + matcher.appendTail(result); + + return result.toString(); + } +} diff --git a/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java b/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java index 9520142..e086565 100644 --- a/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java +++ b/src/main/java/com/wellmeet/notification/webpush/domain/PushSubscription.java @@ -31,14 +31,11 @@ public class PushSubscription extends BaseEntity { @NotNull private String auth; - private boolean active; - public PushSubscription(String userId, String endpoint, String p256dh, String auth) { this.userId = userId; this.endpoint = endpoint; this.p256dh = p256dh; this.auth = auth; - this.active = true; } public void update(PushSubscription updatedSubscription) { diff --git a/src/main/resources/templates/email-notification.html b/src/main/resources/templates/email-notification.html new file mode 100644 index 0000000..4b6f47e --- /dev/null +++ b/src/main/resources/templates/email-notification.html @@ -0,0 +1,77 @@ + + + + + + + +
+
+

{title}

+
+
+

{body}

+
+ + +
+ + diff --git a/src/test/java/com/wellmeet/BaseControllerTest.java b/src/test/java/com/wellmeet/BaseControllerTest.java index 3371b5f..0a47de4 100644 --- a/src/test/java/com/wellmeet/BaseControllerTest.java +++ b/src/test/java/com/wellmeet/BaseControllerTest.java @@ -1,5 +1,6 @@ package com.wellmeet; +import com.wellmeet.config.EmailTestConfig; import com.wellmeet.config.WebPushTestConfig; import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import io.restassured.RestAssured; @@ -18,7 +19,7 @@ @ExtendWith(DataBaseCleaner.class) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@Import(WebPushTestConfig.class) +@Import({WebPushTestConfig.class, EmailTestConfig.class}) public abstract class BaseControllerTest { @Autowired diff --git a/src/test/java/com/wellmeet/BaseServiceTest.java b/src/test/java/com/wellmeet/BaseServiceTest.java index 38e8b0a..32e3083 100644 --- a/src/test/java/com/wellmeet/BaseServiceTest.java +++ b/src/test/java/com/wellmeet/BaseServiceTest.java @@ -1,5 +1,6 @@ package com.wellmeet; +import com.wellmeet.config.EmailTestConfig; import com.wellmeet.config.WebPushTestConfig; import com.wellmeet.notification.webpush.repository.PushSubscriptionRepository; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,7 +12,7 @@ @ExtendWith(DataBaseCleaner.class) @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) -@Import(WebPushTestConfig.class) +@Import({WebPushTestConfig.class, EmailTestConfig.class}) public abstract class BaseServiceTest { @Autowired diff --git a/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java b/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java index 40ac6ac..1f43427 100644 --- a/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java +++ b/src/test/java/com/wellmeet/WellmeetNotificationApplicationTests.java @@ -1,5 +1,6 @@ package com.wellmeet; +import com.wellmeet.config.EmailTestConfig; import com.wellmeet.config.WebPushTestConfig; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -8,7 +9,7 @@ @SpringBootTest @ActiveProfiles("test") -@Import(WebPushTestConfig.class) +@Import({WebPushTestConfig.class, EmailTestConfig.class}) class WellmeetNotificationApplicationTests { @Test diff --git a/src/test/java/com/wellmeet/config/EmailTestConfig.java b/src/test/java/com/wellmeet/config/EmailTestConfig.java new file mode 100644 index 0000000..83d8a08 --- /dev/null +++ b/src/test/java/com/wellmeet/config/EmailTestConfig.java @@ -0,0 +1,18 @@ +package com.wellmeet.config; + +import static org.mockito.Mockito.mock; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.mail.javamail.JavaMailSender; + +@TestConfiguration +public class EmailTestConfig { + + @Bean + @Primary + public JavaMailSender javaMailSender() { + return mock(JavaMailSender.class); + } +} diff --git a/src/test/java/com/wellmeet/email/EmailSenderTest.java b/src/test/java/com/wellmeet/email/EmailSenderTest.java new file mode 100644 index 0000000..1b62646 --- /dev/null +++ b/src/test/java/com/wellmeet/email/EmailSenderTest.java @@ -0,0 +1,156 @@ +package com.wellmeet.email; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.wellmeet.config.EmailTestConfig; +import com.wellmeet.exception.ErrorCode; +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.consumer.dto.NotificationInfo; +import com.wellmeet.notification.consumer.dto.NotificationMessage; +import com.wellmeet.notification.consumer.dto.NotificationType; +import com.wellmeet.notification.domain.NotificationChannel; +import com.wellmeet.notification.email.domain.EmailSubscription; +import com.wellmeet.notification.email.repository.EmailSubscriptionRepository; +import com.wellmeet.notification.email.sender.EmailSender; +import com.wellmeet.notification.email.sender.MailTransport; +import com.wellmeet.notification.email.sender.MailViewRenderer; +import com.wellmeet.notification.template.NotificationTemplateData; +import com.wellmeet.notification.template.NotificationTemplateFactory; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +@Import(EmailTestConfig.class) +class EmailSenderTest { + + @Mock + private EmailSubscriptionRepository emailSubscriptionRepository; + + @Mock + private NotificationTemplateFactory templateFactory; + + @Mock + private MailViewRenderer mailViewRenderer; + + @Mock + private MailTransport mailTransport; + + @InjectMocks + private EmailSender emailSender; + + @Nested + class IsEnabled { + + @Test + void EMAIL_채널이면_true를_반환한다() { + boolean enabled = emailSender.isEnabled(NotificationChannel.EMAIL); + + assertThat(enabled).isTrue(); + + } + + @Test + void WEB_PUSH_채널이면_false를_반환한다() { + boolean enabled = emailSender.isEnabled(NotificationChannel.WEB_PUSH); + + assertThat(enabled).isFalse(); + } + } + + @Nested + class Send { + + @Test + void 이메일을_성공적으로_발송한다() { + NotificationMessage notificationMessage = createNotificationMessage(); + NotificationTemplateData templateData = createTemplateData(); + EmailSubscription subscription = new EmailSubscription("user123", "test@example.com"); + + when(emailSubscriptionRepository.findByUserId("user123")).thenReturn(Optional.of(subscription)); + when(templateFactory.createTemplateData( + NotificationType.RESERVATION_CREATED, + notificationMessage.getPayload() + )).thenReturn(templateData); + when(mailViewRenderer.render(any(), any())).thenReturn("test"); + doNothing().when(mailTransport).send(any(), any(), any()); + + assertThatCode(() -> emailSender.send(notificationMessage)) + .doesNotThrowAnyException(); + + verify(mailTransport).send(any(), any(), any()); + } + + @Test + void 이메일_발송_실패시_예외를_던진다() { + NotificationMessage notificationMessage = createNotificationMessage(); + NotificationTemplateData templateData = createTemplateData(); + EmailSubscription subscription = new EmailSubscription("user123", "test@example.com"); + + when(emailSubscriptionRepository.findByUserId("user123")).thenReturn(Optional.of(subscription)); + when(templateFactory.createTemplateData( + NotificationType.RESERVATION_CREATED, + notificationMessage.getPayload() + )).thenReturn(templateData); + when(mailViewRenderer.render(any(), any())).thenReturn("test"); + doThrow(new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED)) + .when(mailTransport).send(any(), any(), any()); + + assertThatThrownBy(() -> emailSender.send(notificationMessage)) + .isInstanceOf(WellMeetNotificationException.class) + .hasMessageContaining(ErrorCode.EMAIL_SEND_FAILED.getMessage()); + } + + @Test + void 이메일_구독_정보가_없으면_예외를_던진다() { + NotificationMessage notificationMessage = createNotificationMessage(); + + when(emailSubscriptionRepository.findByUserId("user123")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> emailSender.send(notificationMessage)) + .isInstanceOf(WellMeetNotificationException.class) + .hasMessageContaining(ErrorCode.EMAIL_NOT_FOUND.getMessage()); + } + } + + private NotificationMessage createNotificationMessage() { + NotificationInfo notificationInfo = new NotificationInfo( + NotificationType.RESERVATION_CREATED, + "user123" + ); + + return new NotificationMessage( + null, + notificationInfo, + Map.of( + "restaurantName", "테스트 식당", + "reservationTime", "2025-10-08 18:00", + "reservationId", "123" + ) + ); + } + + private NotificationTemplateData createTemplateData() { + return new NotificationTemplateData( + "새로운 예약이 접수되었습니다", + "테스트 식당에 새로운 예약이 접수되었습니다. 예약 시간: 2025-10-08 18:00", + "/reservations/123", + true + ); + } +} diff --git a/src/test/java/com/wellmeet/email/MailViewRendererTest.java b/src/test/java/com/wellmeet/email/MailViewRendererTest.java new file mode 100644 index 0000000..101fdc2 --- /dev/null +++ b/src/test/java/com/wellmeet/email/MailViewRendererTest.java @@ -0,0 +1,99 @@ +package com.wellmeet.email; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.wellmeet.exception.WellMeetNotificationException; +import com.wellmeet.notification.email.sender.MailViewRenderer; +import java.util.Map; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MailViewRendererTest { + + @InjectMocks + private MailViewRenderer mailViewRenderer; + + @Nested + class Render { + + @Test + void 플레이스홀더를_정상적으로_치환한다() { + Map variables = Map.of( + "title", "테스트 제목", + "body", "테스트 내용" + ); + + String result = mailViewRenderer.render("test-template.html", variables); + + assertThat(result).contains("테스트 제목") + .contains("테스트 내용") + .doesNotContain("{title}") + .doesNotContain("{body}"); + } + + @Test + void 존재하지_않는_키는_원본_플레이스홀더를_유지한다() { + Map variables = Map.of( + "title", "테스트 제목", + "body", "테스트 내용" + ); + + String result = mailViewRenderer.render("test-template.html", variables); + + assertThat(result).contains("{unknown}"); + } + + @Test + void 값에_특수문자가_포함되어도_안전하게_치환한다() { + Map variables = Map.of( + "content", "$100 & " + ); + + String result = mailViewRenderer.render("special-chars-template.html", variables); + + assertThat(result).contains("$100") + .contains("&") + .contains("