Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/main/java/com/wellmeet/config/EmailConfig.java
Original file line number Diff line number Diff line change
@@ -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();
}
Comment on lines +13 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

SMTP 서버 설정이 누락되었습니다.

JavaMailSenderImpl이 기본 생성자로 생성되어 SMTP 서버 설정이 없습니다. 이메일 전송 시 런타임 에러가 발생합니다.

다음 설정을 추가해야 합니다:

+import org.springframework.beans.factory.annotation.Value;
+
 @Configuration
 @Profile("!test")
 public class EmailConfig {
 
+    @Value("${spring.mail.host}")
+    private String host;
+
+    @Value("${spring.mail.port}")
+    private int port;
+
+    @Value("${spring.mail.username}")
+    private String username;
+
+    @Value("${spring.mail.password}")
+    private String password;
+
+    @Value("${spring.mail.properties.mail.smtp.auth:true}")
+    private boolean auth;
+
+    @Value("${spring.mail.properties.mail.smtp.starttls.enable:true}")
+    private boolean starttls;
+
     @Bean
     public JavaMailSender javaMailSender() {
-        return new JavaMailSenderImpl();
+        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
+        mailSender.setHost(host);
+        mailSender.setPort(port);
+        mailSender.setUsername(username);
+        mailSender.setPassword(password);
+
+        Properties props = mailSender.getJavaMailProperties();
+        props.put("mail.transport.protocol", "smtp");
+        props.put("mail.smtp.auth", auth);
+        props.put("mail.smtp.starttls.enable", starttls);
+        props.put("mail.debug", "false");
+
+        return mailSender;
     }
 }

또한 application.yml 또는 application.properties에 다음 설정을 추가하세요:

spring:
  mail:
    host: smtp.example.com
    port: 587
    username: your-email@example.com
    password: your-password
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
🤖 Prompt for AI Agents
In src/main/java/com/wellmeet/config/EmailConfig.java around lines 13-16, the
JavaMailSender bean is created with the default constructor so no SMTP
host/port/credentials or mail properties are set; replace the current
implementation to populate a JavaMailSenderImpl from application properties
(inject Environment or @Value for spring.mail.host, port, username, password and
set them on the JavaMailSenderImpl) and set its JavaMailProperties
(mail.smtp.auth and mail.smtp.starttls.enable) accordingly, and add the
recommended spring.mail.* entries to application.yml or application.properties
so the bean picks up real SMTP configuration.

}
2 changes: 2 additions & 0 deletions src/main/java/com/wellmeet/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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, "입력이 잘못되었습니다."),
Expand All @@ -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, "알림 템플릿을 찾을 수 없습니다.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ public class NotificationMessage {
private NotificationInfo notification;
private Map<String, Object> payload;

public NotificationMessage(MessageHeader header, NotificationInfo notification, Map<String, Object> payload) {
this.header = header;
this.notification = notification;
this.payload = payload;
}

public String getRecipient() {
return notification.getRecipient();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

userId 필드에 대한 최대 길이를 지정하는 것이 좋습니다. @Column(length = ...) 어노테이션을 사용하여 데이터베이스 스키마에 제약 조건을 명시하고, 데이터의 일관성을 보장할 수 있습니다. userId의 길이에 맞는 적절한 값을 설정해주세요. (예: 50자) jakarta.persistence.Column 임포트가 필요합니다.

Suggested change
@NotNull
@jakarta.persistence.Column(length = 50)
@NotNull

private String userId;

@NotNull
@Email

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

email 필드에 대한 최대 길이를 지정하는 것이 좋습니다. RFC 표준에 따르면 이메일 주소의 최대 길이는 254자입니다. @Column(length = 254)를 추가하여 데이터베이스 수준에서 제약 조건을 강제하는 것이 좋습니다. jakarta.persistence.Column 임포트가 필요합니다.

Suggested change
@Email
@jakarta.persistence.Column(length = 254)
@Email

private String email;

public EmailSubscription(String userId, String email) {
this.userId = userId;
this.email = email;
}
}
Original file line number Diff line number Diff line change
@@ -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<EmailSubscription, Long> {

Optional<EmailSubscription> findByUserId(String userId);
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

포괄적인 Exception을 잡는 것보다, 이메일 전송 시 발생할 수 있는 더 구체적인 예외를 명시하는 것이 좋습니다. Spring의 MailException과 Jakarta Mail의 MessagingException을 잡도록 수정하면 코드의 의도가 명확해지고 예상치 못한 다른 종류의 Exception이 가려지는 것을 방지할 수 있습니다. org.springframework.mail.MailExceptionjakarta.mail.MessagingException 임포트가 필요합니다.

Suggested change
} catch (Exception e) {
} catch (MailException | MessagingException e) {

log.error("이메일 전송에 실패했습니다. to: {}, subject: {}", to, subject, e);
throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED);
}
Comment on lines +29 to +32

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] 보다 구체적인 예외 처리 및 원인 예외(cause) 보존 필요

현재 catch (Exception e) 블록은 너무 광범위하여 이메일 전송과 관련 없는 예외(예: NullPointerException)까지 모두 EMAIL_SEND_FAILED로 처리할 수 있어 디버깅을 어렵게 만듭니다.

  • 구체적인 예외 처리: Spring의 JavaMailSenderMailException을, MimeMessageHelperMessagingException을 발생시키므로, 이들을 명시적으로 잡는 것이 좋습니다.
  • 원인 예외 보존: log.error로 예외를 기록하고 있지만, 새로운 WellMeetNotificationException을 던질 때 원래 예외(e)를 포함하지 않아 스택 트레이스 정보가 유실됩니다. 이는 문제 발생 시 원인 추적을 매우 어렵게 만듭니다.

WellMeetNotificationException에 원인 예외를 전달하는 생성자를 추가하고, catch 블록에서 이를 사용하도록 수정하는 것을 강력히 권장합니다. 만약 WellMeetNotificationException 수정이 당장 어렵다면, 잡는 예외라도 아래와 같이 더 구체적으로 변경하는 것이 좋습니다.

Suggested change
} catch (Exception e) {
log.error("이메일 전송에 실패했습니다. to: {}, subject: {}", to, subject, e);
throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED);
}
} catch (org.springframework.mail.MailException | jakarta.mail.MessagingException e) {
log.error("이메일 전송에 실패했습니다. to: {}, subject: {}", to, subject, e);
throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED);
}

}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[HIGH] 상황에 맞지 않는 에러 코드 사용

이메일 템플릿 로딩에 실패했을 때 ErrorCode.EMAIL_SEND_FAILED를 사용하고 있습니다. 이는 실제 에러의 원인(템플릿 로딩 실패)과 달라 혼동을 줄 수 있습니다.

ErrorCode.TEMPLATE_NOT_FOUND와 같이 상황에 더 적합한 에러 코드를 사용하는 것이 원인 파악에 더 도움이 됩니다. 또한, MailTransport.java와 마찬가지로 원인 예외(e)를 함께 전달하여 스택 트레이스를 보존하는 것이 좋습니다.

Suggested change
throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED);
throw new WellMeetNotificationException(ErrorCode.TEMPLATE_NOT_FOUND);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

템플릿 로딩 실패 시 EMAIL_SEND_FAILED 에러 코드를 사용하고 있습니다. 하지만 ErrorCodeTEMPLATE_NOT_FOUND가 이미 정의되어 있고, NotificationTemplateFactory에서도 사용되고 있습니다. 오류의 원인을 더 명확하게 나타내고 일관성을 유지하기 위해 TEMPLATE_NOT_FOUND를 사용하는 것이 좋겠습니다.

Suggested change
throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED);
throw new WellMeetNotificationException(ErrorCode.TEMPLATE_NOT_FOUND);

}
}

private String replacePlaceholders(String template, Map<String, String> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
77 changes: 77 additions & 0 deletions src/main/resources/templates/email-notification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<style>
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}

.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.header {
border-bottom: 3px solid #4A90E2;
padding-bottom: 20px;
margin-bottom: 30px;
}

.title {
color: #4A90E2;
font-size: 24px;
font-weight: bold;
margin: 0;
}

.content {
font-size: 16px;
color: #555;
margin-bottom: 30px;
}

.action-button {
display: inline-block;
padding: 12px 30px;
background-color: #4A90E2;
color: #ffffff;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
}

.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
font-size: 12px;
color: #999;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 class="title">{title}</h1>
</div>
<div class="content">
<p>{body}</p>
</div>
<div style="text-align: center;">
<a href="{url}" class="action-button">자세히 보기</a>
</div>
<div class="footer">
<p>이 이메일은 WellMeet 알림 서비스에서 자동으로 발송되었습니다.</p>
</div>
</div>
</body>
</html>
3 changes: 2 additions & 1 deletion src/test/java/com/wellmeet/BaseControllerTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/test/java/com/wellmeet/BaseServiceTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -8,7 +9,7 @@

@SpringBootTest
@ActiveProfiles("test")
@Import(WebPushTestConfig.class)
@Import({WebPushTestConfig.class, EmailTestConfig.class})
class WellmeetNotificationApplicationTests {

@Test
Expand Down
Loading