diff --git a/config b/config index f99b5394..d6991821 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit f99b5394463d91b5c31a7907dd48c81e1760ee61 +Subproject commit d69918214bb7bdb96031e7c45d3cbdef1aec1834 diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java new file mode 100644 index 00000000..c5c3d6f1 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -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, e); + } + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java new file mode 100644 index 00000000..600e70e1 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java @@ -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); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java new file mode 100644 index 00000000..b8b7dab3 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java @@ -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 { +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java new file mode 100644 index 00000000..63e4b42c --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java @@ -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 findAll() { + return repository.findAll(); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java new file mode 100644 index 00000000..0ecd52aa --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java @@ -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 { +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java new file mode 100644 index 00000000..f6492534 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -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 send( + @Valid @RequestBody BackofficeMailSendRequest request + ) { + backofficeMailSendUseCase.send(request.toInput()); + return ApiResponse.success("이메일 전송에 성공하였습니다."); + } + + @PostMapping("/v1/backoffice/mail/templates") + public ApiResponse createTemplate( + @Valid @RequestBody BackofficeMailTemplateCreateRequest request + ) { + BackofficeMailTemplateResponse response = BackofficeMailTemplateResponse.from(templateUseCase.createTemplate(request.toInput())); + return ApiResponse.success(response); + } + + @GetMapping("/v1/backoffice/mail/templates") + public ApiResponse> findTemplates() { + return ApiResponse.success(templateUseCase.findTemplates().stream() + .map(BackofficeMailTemplateResponse::from) + .toList()); + } + + @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") + public ApiResponse deleteTemplate( + @PathVariable Long templateId + ) { + templateUseCase.deleteTemplate(templateId); + return ApiResponse.success("템플릿이 삭제되었습니다."); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java new file mode 100644 index 00000000..616df1cf --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -0,0 +1,41 @@ +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 jakarta.validation.constraints.Pattern; +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") + @Pattern(regexp = "(?i)^(html|text)$", message = "contentType must be html or text") + 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; + } + + public BackofficeMailSendInput toInput() { + return BackofficeMailSendInput.of(to, subject, contentType, html, text); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java new file mode 100644 index 00000000..81974a64 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java @@ -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); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java new file mode 100644 index 00000000..c8d9533d --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java @@ -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() + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java new file mode 100644 index 00000000..41928262 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -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); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java new file mode 100644 index 00000000..07243780 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -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 + )); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java new file mode 100644 index 00000000..bdd12e9f --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -0,0 +1,87 @@ +package starlight.application.backoffice.mail; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; +import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; +import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailContentType; +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackofficeMailTemplateService implements BackofficeMailTemplateUseCase { + + private final BackofficeMailTemplateCommandPort templateCommandPort; + private final BackofficeMailTemplateQueryPort templateQueryPort; + + @Override + @Transactional + public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { + BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailTemplate template = BackofficeMailTemplate.create( + input.name(), + input.title(), + contentType, + input.html(), + input.text() + ); + + try { + BackofficeMailTemplate saved = templateCommandPort.save(template); + return toResult(saved); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_SAVE_FAILED); + } + } + + private BackofficeMailContentType parseContentType(String contentType) { + try { + return BackofficeMailContentType.from(contentType); + } catch (IllegalArgumentException exception) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); + } + } + + @Override + @Transactional(readOnly = true) + public List findTemplates() { + try { + return templateQueryPort.findAll().stream() + .map(this::toResult) + .toList(); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_QUERY_FAILED); + } + } + + @Override + @Transactional + public void deleteTemplate(Long templateId) { + try { + templateCommandPort.deleteById(templateId); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_DELETE_FAILED); + } + } + + private BackofficeMailTemplateResult toResult(BackofficeMailTemplate template) { + return BackofficeMailTemplateResult.of( + template.getId(), + template.getName(), + template.getEmailTitle(), + template.getContentType().name().toLowerCase(), + template.getHtml(), + template.getText(), + template.getCreatedAt() + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java new file mode 100644 index 00000000..b13163b1 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java @@ -0,0 +1,23 @@ +package starlight.application.backoffice.mail.event; + +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +import java.util.List; + +public record BackofficeMailSendEvent( + List to, + String subject, + BackofficeMailContentType contentType, + boolean success, + String errorMessage +) { + public static BackofficeMailSendEvent of( + List to, + String subject, + BackofficeMailContentType contentType, + boolean success, + String errorMessage + ) { + return new BackofficeMailSendEvent(to, subject, contentType, success, errorMessage); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java new file mode 100644 index 00000000..37824882 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java @@ -0,0 +1,8 @@ +package starlight.application.backoffice.mail.provided; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; + +public interface BackofficeMailSendUseCase { + + void send(BackofficeMailSendInput input); +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java new file mode 100644 index 00000000..b79c0896 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java @@ -0,0 +1,15 @@ +package starlight.application.backoffice.mail.provided; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; + +import java.util.List; + +public interface BackofficeMailTemplateUseCase { + + BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input); + + List findTemplates(); + + void deleteTemplate(Long templateId); +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java new file mode 100644 index 00000000..dde0c20c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java @@ -0,0 +1,21 @@ +package starlight.application.backoffice.mail.provided.dto.input; + +import java.util.List; + +public record BackofficeMailSendInput( + List to, + String subject, + String contentType, + String html, + String text +) { + public static BackofficeMailSendInput of( + List to, + String subject, + String contentType, + String html, + String text + ) { + return new BackofficeMailSendInput(to, subject, contentType, html, text); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java new file mode 100644 index 00000000..83867cf0 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java @@ -0,0 +1,19 @@ +package starlight.application.backoffice.mail.provided.dto.input; + +public record BackofficeMailTemplateCreateInput( + String name, + String title, + String contentType, + String html, + String text +) { + public static BackofficeMailTemplateCreateInput of( + String name, + String title, + String contentType, + String html, + String text + ) { + return new BackofficeMailTemplateCreateInput(name, title, contentType, html, text); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java new file mode 100644 index 00000000..bbe225b6 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java @@ -0,0 +1,33 @@ +package starlight.application.backoffice.mail.provided.dto.result; + +import java.time.LocalDateTime; + +public record BackofficeMailTemplateResult( + Long id, + String name, + String title, + String contentType, + String html, + String text, + LocalDateTime createdAt +) { + public static BackofficeMailTemplateResult of( + Long id, + String name, + String title, + String contentType, + String html, + String text, + LocalDateTime createdAt + ) { + return new BackofficeMailTemplateResult( + id, + name, + title, + contentType, + html, + text, + createdAt + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java new file mode 100644 index 00000000..7d0815e0 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java @@ -0,0 +1,8 @@ +package starlight.application.backoffice.mail.required; + +import starlight.domain.backoffice.mail.BackofficeMailSendLog; + +public interface BackofficeMailSendLogCommandPort { + + BackofficeMailSendLog save(BackofficeMailSendLog log); +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java new file mode 100644 index 00000000..ff1bd221 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.mail.required; + +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +public interface BackofficeMailTemplateCommandPort { + + BackofficeMailTemplate save(BackofficeMailTemplate template); + + void deleteById(Long templateId); +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java new file mode 100644 index 00000000..797933c4 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.mail.required; + +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +import java.util.List; + +public interface BackofficeMailTemplateQueryPort { + + List findAll(); +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java b/src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java new file mode 100644 index 00000000..8da3a10a --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.mail.required; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public interface MailSenderPort { + + void send(BackofficeMailSendInput input, BackofficeMailContentType contentType); +} diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index dd30857e..07f42329 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -8,6 +8,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -15,6 +17,14 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; @@ -39,8 +49,12 @@ public class SecurityConfig { @Value("${cors.origin.server}") String ServerBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; + @Value("${cors.origin.office}") String officeBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; + @Value("${backoffice.auth.username}") String backofficeUsername; + @Value("${backoffice.auth.password-hash}") String backofficePasswordHash; + private final Environment environment; private final JwtFilter jwtFilter; private final ExceptionFilter exceptionFilter; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @@ -49,6 +63,39 @@ public class SecurityConfig { private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean + @Order(1) + public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Exception { + CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); + CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + boolean isDevProfile = List.of(environment.getActiveProfiles()).contains("dev"); + if (!isDevProfile) { + csrfTokenRepository.setCookieCustomizer(cookie -> cookie + .domain("starlight-official.co.kr") + .sameSite("None") + .secure(true) + ); + } + + http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") + .cors(Customizer.withDefaults()) + .csrf((csrf) -> csrf + .csrfTokenRepository(csrfTokenRepository) + .csrfTokenRequestHandler(csrfTokenRequestHandler) + ) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .authorizeHttpRequests((authorize) -> + authorize + .requestMatchers("/login", "/logout").permitAll() + .anyRequest().hasRole("BACKOFFICE") + ) + .formLogin(Customizer.withDefaults()) + .logout(Customizer.withDefaults()); + + return http.build(); + } + + @Bean + @Order(2) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable); @@ -102,7 +149,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, ServerBaseUrl, - devBaseUrl + devBaseUrl, + officeBaseUrl )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); @@ -122,8 +170,33 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public UserDetailsService userDetailsService() { + if (backofficePasswordHash == null || backofficePasswordHash.isBlank()) { + throw new IllegalStateException("backoffice.auth.password-hash must be configured"); + } + UserDetails user = User.builder() + .username(backofficeUsername) + .password(backofficePasswordHash) + .roles("BACKOFFICE") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + public AuthenticationProvider authenticationProvider( + UserDetailsService userDetailsService, + PasswordEncoder passwordEncoder + ) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; + } + @Bean public LogoutSuccessHandler logoutSuccessHandler() { return new HttpStatusReturningLogoutSuccessHandler(); } + } diff --git a/src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java b/src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java new file mode 100644 index 00000000..a1d9f1f6 --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java @@ -0,0 +1,24 @@ +package starlight.domain.backoffice.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum BackofficeErrorType implements ErrorType { + + INVALID_MAIL_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 contentType입니다."), + INVALID_MAIL_REQUEST(HttpStatus.BAD_REQUEST, "메일 발송 요청이 유효하지 않습니다."), + MAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 전송에 실패했습니다."), + MAIL_TEMPLATE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 템플릿 저장에 실패했습니다."), + MAIL_TEMPLATE_QUERY_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 템플릿 조회에 실패했습니다."), + MAIL_TEMPLATE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 템플릿 삭제에 실패했습니다."), + MAIL_LOG_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 로그 저장에 실패했습니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java new file mode 100644 index 00000000..008b2d87 --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java @@ -0,0 +1,15 @@ +package starlight.domain.backoffice.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class BackofficeException extends GlobalException { + + public BackofficeException(ErrorType errorType) { + super(errorType); + } + + public BackofficeException(ErrorType errorType, Throwable cause) { + super(errorType, cause); + } +} diff --git a/src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java new file mode 100644 index 00000000..308c8bf7 --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java @@ -0,0 +1,26 @@ +package starlight.domain.backoffice.mail; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BackofficeMailContentType { + HTML("html"), + TEXT("텍스트"); + + private final String description; + + public static BackofficeMailContentType from(String value) { + if (value == null) { + throw new IllegalArgumentException("contentType is required"); + } + if ("html".equalsIgnoreCase(value)) { + return HTML; + } + if ("text".equalsIgnoreCase(value)) { + return TEXT; + } + throw new IllegalArgumentException("invalid contentType"); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java new file mode 100644 index 00000000..dc29114e --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java @@ -0,0 +1,50 @@ +package starlight.domain.backoffice.mail; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.shared.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BackofficeMailSendLog extends AbstractEntity { + + @Column(nullable = false, columnDefinition = "TEXT") + private String recipients; + + @Column(nullable = false) + private String emailTitle; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private BackofficeMailContentType contentType; + + @Column(nullable = false) + private boolean success; + + @Column(columnDefinition = "TEXT") + private String errorMessage; + + public static BackofficeMailSendLog create( + String recipients, String emailTitle, BackofficeMailContentType contentType, boolean success, String errorMessage + ) { + Assert.hasText(recipients, "recipients must not be empty"); + Assert.hasText(emailTitle, "subject must not be empty"); + Assert.notNull(contentType, "contentType must not be null"); + + BackofficeMailSendLog log = new BackofficeMailSendLog(); + log.recipients = recipients; + log.emailTitle = emailTitle; + log.contentType = contentType; + log.success = success; + log.errorMessage = errorMessage; + + return log; + } +} diff --git a/src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java new file mode 100644 index 00000000..feee431f --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java @@ -0,0 +1,50 @@ +package starlight.domain.backoffice.mail; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.shared.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BackofficeMailTemplate extends AbstractEntity { + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String emailTitle; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private BackofficeMailContentType contentType; + + @Column(columnDefinition = "TEXT") + private String html; + + @Column(columnDefinition = "TEXT") + private String text; + + public static BackofficeMailTemplate create( + String name, String emailTitle, BackofficeMailContentType contentType, String html, String text + ) { + Assert.hasText(name, "name must not be empty"); + Assert.hasText(emailTitle, "title must not be empty"); + Assert.notNull(contentType, "contentType must not be null"); + + BackofficeMailTemplate template = new BackofficeMailTemplate(); + template.name = name; + template.emailTitle = emailTitle; + template.contentType = contentType; + template.html = html; + template.text = text; + + return template; + } +} diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java index 1c7a8198..7ecbd40f 100644 --- a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java @@ -11,4 +11,9 @@ public GlobalException(ErrorType errorType) { super(errorType.getMessage()); this.errorType = errorType; } -} \ No newline at end of file + + public GlobalException(ErrorType errorType, Throwable cause) { + super(errorType.getMessage(), cause); + this.errorType = errorType; + } +}