-
Notifications
You must be signed in to change notification settings - Fork 1
[SRLT-132] 백오피스에서 메일을 전송한다 #79
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
base: develop
Are you sure you want to change the base?
The head ref may contain hidden characters: "SRLT-132-\uBC31\uC624\uD53C\uC2A4-\uC774\uBA54\uC77C-\uD398\uC774\uC9C0"
Changes from 16 commits
17b687d
81409ec
ef24a8c
e5b3956
39f4e87
cb3023e
417fe2f
1c82af1
1d5973d
824890f
9301af1
fe0e2c3
89ac496
f45cb62
73f59b0
1677d6c
b70d0a1
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,48 @@ | ||
| package starlight.adapter.backoffice.mail.email; | ||
|
|
||
| import jakarta.mail.MessagingException; | ||
| import jakarta.mail.internet.MimeMessage; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.mail.javamail.JavaMailSender; | ||
| import org.springframework.mail.javamail.MimeMessageHelper; | ||
| import org.springframework.stereotype.Service; | ||
| import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; | ||
| import starlight.application.backoffice.mail.required.MailSenderPort; | ||
| import starlight.domain.backoffice.exception.BackofficeErrorType; | ||
| import starlight.domain.backoffice.exception.BackofficeException; | ||
| import starlight.domain.backoffice.mail.BackofficeMailContentType; | ||
|
|
||
| @Slf4j | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class SmtpMailSender implements MailSenderPort { | ||
|
|
||
| private final JavaMailSender javaMailSender; | ||
|
|
||
| @Value("${spring.mail.username}") | ||
| private String senderEmail; | ||
|
|
||
| @Override | ||
| public void send(BackofficeMailSendInput input, BackofficeMailContentType contentType) { | ||
| try { | ||
| MimeMessage message = javaMailSender.createMimeMessage(); | ||
| MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); | ||
|
|
||
| helper.setFrom(senderEmail); | ||
| helper.setTo(input.to().toArray(new String[0])); | ||
| helper.setSubject(input.subject()); | ||
|
|
||
| boolean isHtml = contentType == BackofficeMailContentType.HTML; | ||
| String body = isHtml ? input.html() : input.text(); | ||
| helper.setText(body, isHtml); | ||
|
|
||
| javaMailSender.send(message); | ||
| log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); | ||
| } catch (MessagingException e) { | ||
| log.error("[MAIL] send failed to={}", input.to(), e); | ||
| throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package starlight.adapter.backoffice.mail.persistence; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Repository; | ||
| import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; | ||
| import starlight.domain.backoffice.mail.BackofficeMailSendLog; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class BackofficeMailSendLogJpa implements BackofficeMailSendLogCommandPort { | ||
|
|
||
| private final BackofficeMailSendLogRepository repository; | ||
|
|
||
| @Override | ||
| public BackofficeMailSendLog save(BackofficeMailSendLog log) { | ||
| return repository.save(log); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package starlight.adapter.backoffice.mail.persistence; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import starlight.domain.backoffice.mail.BackofficeMailSendLog; | ||
|
|
||
| public interface BackofficeMailSendLogRepository extends JpaRepository<BackofficeMailSendLog, Long> { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package starlight.adapter.backoffice.mail.persistence; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Repository; | ||
| import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; | ||
| import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; | ||
| import starlight.domain.backoffice.mail.BackofficeMailTemplate; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Repository | ||
| @RequiredArgsConstructor | ||
| public class BackofficeMailTemplateJpa implements BackofficeMailTemplateCommandPort, BackofficeMailTemplateQueryPort { | ||
|
|
||
| private final BackofficeMailTemplateRepository repository; | ||
|
|
||
| @Override | ||
| public BackofficeMailTemplate save(BackofficeMailTemplate template) { | ||
| return repository.save(template); | ||
| } | ||
|
|
||
| @Override | ||
| public void deleteById(Long templateId) { | ||
| repository.deleteById(templateId); | ||
| } | ||
|
|
||
| @Override | ||
| public List<BackofficeMailTemplate> findAll() { | ||
| return repository.findAll(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package starlight.adapter.backoffice.mail.persistence; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import starlight.domain.backoffice.mail.BackofficeMailTemplate; | ||
|
|
||
| public interface BackofficeMailTemplateRepository extends JpaRepository<BackofficeMailTemplate, Long> { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| package starlight.adapter.backoffice.mail.webapi; | ||
|
|
||
| import jakarta.validation.Valid; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.*; | ||
| import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; | ||
| import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; | ||
| import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; | ||
| import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; | ||
| import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; | ||
| import starlight.shared.apiPayload.response.ApiResponse; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| public class BackofficeMailController { | ||
|
|
||
| private final BackofficeMailSendUseCase backofficeMailSendUseCase; | ||
| private final BackofficeMailTemplateUseCase templateUseCase; | ||
|
|
||
| @PostMapping("/v1/backoffice/mail/send") | ||
| public ApiResponse<String> send( | ||
| @Valid @RequestBody BackofficeMailSendRequest request | ||
| ) { | ||
| backofficeMailSendUseCase.send(request.toInput()); | ||
| return ApiResponse.success("이메일 전송에 성공하였습니다."); | ||
| } | ||
|
|
||
| @PostMapping("/v1/backoffice/mail/templates") | ||
| public ApiResponse<BackofficeMailTemplateResponse> createTemplate( | ||
| @Valid @RequestBody BackofficeMailTemplateCreateRequest request | ||
| ) { | ||
| BackofficeMailTemplateResponse response = BackofficeMailTemplateResponse.from(templateUseCase.createTemplate(request.toInput())); | ||
| return ApiResponse.success(response); | ||
| } | ||
|
|
||
| @GetMapping("/v1/backoffice/mail/templates") | ||
| public ApiResponse<List<BackofficeMailTemplateResponse>> findTemplates() { | ||
| return ApiResponse.success(templateUseCase.findTemplates().stream() | ||
| .map(BackofficeMailTemplateResponse::from) | ||
| .toList()); | ||
| } | ||
|
|
||
| @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") | ||
| public ApiResponse<String> deleteTemplate( | ||
| @PathVariable Long templateId | ||
| ) { | ||
| templateUseCase.deleteTemplate(templateId); | ||
| return ApiResponse.success("템플릿이 삭제되었습니다."); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package starlight.adapter.backoffice.mail.webapi.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.Email; | ||
| import jakarta.validation.constraints.AssertTrue; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotEmpty; | ||
| import org.springframework.util.StringUtils; | ||
| import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record BackofficeMailSendRequest( | ||
| @NotEmpty(message = "to is required") | ||
| List<@Email @NotBlank String> to, | ||
| @NotBlank(message = "subject is required") | ||
| String subject, | ||
| @NotBlank(message = "contentType is required") | ||
| String contentType, | ||
| String html, | ||
| String text | ||
| ) { | ||
| @AssertTrue(message = "html is required for html contentType; text is required for text contentType") | ||
| public boolean isBodyProvided() { | ||
| if (!StringUtils.hasText(contentType)) { | ||
| return true; | ||
| } | ||
| if ("html".equalsIgnoreCase(contentType)) { | ||
| return StringUtils.hasText(html); | ||
| } | ||
| if ("text".equalsIgnoreCase(contentType)) { | ||
| return StringUtils.hasText(text); | ||
| } | ||
| return true; | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| public BackofficeMailSendInput toInput() { | ||
| return BackofficeMailSendInput.of(to, subject, contentType, html, text); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package starlight.adapter.backoffice.mail.webapi.dto.request; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; | ||
| public record BackofficeMailTemplateCreateRequest( | ||
| @NotBlank(message = "name is required") | ||
| String name, | ||
| @NotBlank(message = "title is required") | ||
| String title, | ||
| @NotBlank(message = "contentType is required") | ||
| String contentType, | ||
| String html, | ||
| String text | ||
| ) { | ||
| public BackofficeMailTemplateCreateInput toInput() { | ||
| return BackofficeMailTemplateCreateInput.of(name, title, contentType, html, text); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package starlight.adapter.backoffice.mail.webapi.dto.response; | ||
|
|
||
| import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public record BackofficeMailTemplateResponse( | ||
| Long id, | ||
| String name, | ||
| String title, | ||
| String contentType, | ||
| String html, | ||
| String text, | ||
| LocalDateTime createdAt | ||
| ) { | ||
| public static BackofficeMailTemplateResponse from(BackofficeMailTemplateResult result) { | ||
| return new BackofficeMailTemplateResponse( | ||
| result.id(), | ||
| result.name(), | ||
| result.title(), | ||
| result.contentType(), | ||
| result.html(), | ||
| result.text(), | ||
| result.createdAt() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package starlight.application.backoffice.mail; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.event.EventListener; | ||
| import org.springframework.stereotype.Component; | ||
| import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; | ||
| import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; | ||
| import starlight.domain.backoffice.mail.BackofficeMailSendLog; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class BackofficeMailSendLogEventHandler { | ||
|
|
||
| private final BackofficeMailSendLogCommandPort logCommandPort; | ||
|
|
||
| @EventListener | ||
| public void handle(BackofficeMailSendEvent event) { | ||
| String recipients = String.join(",", event.to()); | ||
| BackofficeMailSendLog log = BackofficeMailSendLog.create( | ||
| recipients, | ||
| event.subject(), | ||
| event.contentType(), | ||
| event.success(), | ||
| event.errorMessage() | ||
| ); | ||
| logCommandPort.save(log); | ||
|
Comment on lines
+16
to
+26
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. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 1. Spring 비동기 이벤트 처리 설정 확인
echo "=== Async Configuration Search ==="
rg -n --type=java "ApplicationEventMulticaster|SimpleApplicationEventMulticaster|@EnableAsync|@Async" -A 2
# 2. BackofficeMailSendLogEventHandler 파일 찾기
echo -e "\n=== Finding BackofficeMailSendLogEventHandler ==="
fd -t f "BackofficeMailSendLogEventHandler"
# 3. 이벤트 리스너 패턴 확인
echo -e "\n=== All `@EventListener` usages ==="
rg -n "@EventListener" -A 3 --type=javaRepository: StartUpLight/STARLIGHT_BE Length of output: 1510 🏁 Script executed: #!/bin/bash
# AsyncConfig 전체 구현 확인
echo "=== AsyncConfig Implementation ==="
cat -n src/main/java/starlight/bootstrap/AsyncConfig.java
echo -e "\n=== BackofficeMailSendLogEventHandler Full Implementation ==="
cat -n src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java
echo -e "\n=== FeedbackRequestEventListener for comparison ==="
cat -n src/main/java/starlight/application/expertApplication/event/FeedbackRequestEventListener.javaRepository: StartUpLight/STARLIGHT_BE Length of output: 5715 @eventlistener는 기본적으로 동기 실행이라 로그 저장 예외가 메일 전송 흐름을 차단할 수 있음 현재 구현은 동기 같은 프로젝트의
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| package starlight.application.backoffice.mail; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; | ||
| import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; | ||
| import starlight.application.backoffice.mail.required.MailSenderPort; | ||
| import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; | ||
| import org.springframework.context.ApplicationEventPublisher; | ||
| import starlight.domain.backoffice.exception.BackofficeErrorType; | ||
| import starlight.domain.backoffice.exception.BackofficeException; | ||
| import starlight.domain.backoffice.mail.BackofficeMailContentType; | ||
| import starlight.domain.backoffice.mail.BackofficeMailSendLog; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class BackofficeMailSendService implements BackofficeMailSendUseCase { | ||
|
|
||
| private final MailSenderPort mailSenderPort; | ||
| private final ApplicationEventPublisher eventPublisher; | ||
|
|
||
| @Override | ||
| @Transactional | ||
| public void send(BackofficeMailSendInput input) { | ||
| BackofficeMailContentType contentType = parseContentType(input.contentType()); | ||
|
|
||
| try { | ||
| validate(input, contentType); | ||
| mailSenderPort.send(input, contentType); | ||
| BackofficeMailSendEvent log = BackofficeMailSendEvent.of( | ||
| input.to(), | ||
| input.subject(), | ||
| contentType, | ||
| true, | ||
| null | ||
| ); | ||
| eventPublisher.publishEvent(log); | ||
|
|
||
| } catch (IllegalArgumentException exception) { | ||
| publishFailureEvent(input, contentType, exception.getMessage()); | ||
| throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); | ||
| } catch (Exception exception) { | ||
| publishFailureEvent(input, contentType, exception.getMessage()); | ||
| throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); | ||
| } | ||
| } | ||
|
|
||
| private BackofficeMailContentType parseContentType(String contentType) { | ||
| try { | ||
| return BackofficeMailContentType.from(contentType); | ||
| } catch (IllegalArgumentException exception) { | ||
| throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); | ||
| } | ||
| } | ||
|
|
||
| private void validate(BackofficeMailSendInput input, BackofficeMailContentType contentType) { | ||
| if (input.to() == null || input.to().isEmpty()) { | ||
| throw new IllegalArgumentException("recipient is required"); | ||
| } | ||
| if (input.subject() == null || input.subject().isBlank()) { | ||
| throw new IllegalArgumentException("subject is required"); | ||
| } | ||
| if (contentType == BackofficeMailContentType.HTML) { | ||
| if (input.html() == null || input.html().isBlank()) { | ||
| throw new IllegalArgumentException("html body is required"); | ||
| } | ||
| } | ||
| if (contentType == BackofficeMailContentType.TEXT) { | ||
| if (input.text() == null || input.text().isBlank()) { | ||
| throw new IllegalArgumentException("text body is required"); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private void publishFailureEvent( | ||
| BackofficeMailSendInput input, | ||
| BackofficeMailContentType contentType, | ||
| String errorMessage | ||
| ) { | ||
| eventPublisher.publishEvent(BackofficeMailSendEvent.of( | ||
| input.to(), | ||
| input.subject(), | ||
| contentType, | ||
| false, | ||
| errorMessage | ||
| )); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.