-
Notifications
You must be signed in to change notification settings - Fork 0
SCRUM-126 이메일 알림 기능 #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
| } | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| private String userId; | ||
|
|
||
| @NotNull | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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) { | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 포괄적인
Suggested change
|
||||||||||||||||||
| log.error("이메일 전송에 실패했습니다. to: {}, subject: {}", to, subject, e); | ||||||||||||||||||
| throw new WellMeetNotificationException(ErrorCode.EMAIL_SEND_FAILED); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+29
to
+32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] 보다 구체적인 예외 처리 및 원인 예외(cause) 보존 필요현재
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| 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); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] 상황에 맞지 않는 에러 코드 사용이메일 템플릿 로딩에 실패했을 때
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 템플릿 로딩 실패 시
Suggested change
|
||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| 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 |
|---|---|---|
| @@ -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> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SMTP 서버 설정이 누락되었습니다.
JavaMailSenderImpl이 기본 생성자로 생성되어 SMTP 서버 설정이 없습니다. 이메일 전송 시 런타임 에러가 발생합니다.다음 설정을 추가해야 합니다:
또한
application.yml또는application.properties에 다음 설정을 추가하세요:🤖 Prompt for AI Agents