Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
17b687d
[SRLT-132] feat: 백오피스 이메일 전송 기능을 구현한다
SeongHo5356 Jan 15, 2026
81409ec
[SRLT-132] feat: 백오피스 로그인 기능을 구현한다
SeongHo5356 Jan 15, 2026
ef24a8c
[SRLT-132] feat: event를 통해 로그 저장 수순을 메인로직에서 분리한다
SeongHo5356 Jan 16, 2026
e5b3956
[SRLT-132] Refactor: 백오피스 메일 DTO 생성 로직을 정적 팩토리로 통일한다
SeongHo5356 Jan 16, 2026
39f4e87
[SRLT-132] Chore: 백오피스 주소를 cors 설정에 추가한다
SeongHo5356 Jan 16, 2026
cb3023e
[SRLT-132] Chore: 테스트 환경변수를 최신화한다
SeongHo5356 Jan 16, 2026
417fe2f
[SRLT-132] Fix: CSRF 쿠키 SameSite/Secure 적용
SeongHo5356 Jan 16, 2026
1c82af1
[SRLT-132] Fix: CSRF 쿠키 도메인/SameSite/Secure 설
SeongHo5356 Jan 17, 2026
1d5973d
[SRLT-132] Fix: . 빼기
SeongHo5356 Jan 17, 2026
824890f
[SRLT-132] Fix: CSRF 쿠키 도메인/SameSite/Secure 설정
SeongHo5356 Jan 17, 2026
9301af1
[SRLT-132] Fix: 환경변수 remoteip 설정 추가
SeongHo5356 Jan 17, 2026
fe0e2c3
[SRLT-132] Fix: 환경변수 remoteip 설정 추가
SeongHo5356 Jan 17, 2026
89ac496
[SRLT-132] Fix: 환경별 분리
SeongHo5356 Jan 17, 2026
f45cb62
[SRLT-132] Fix: 백오피스 로그인 리다이렉트 HTTPS 고정
SeongHo5356 Jan 17, 2026
73f59b0
[SRLT-132] Fix: 백오피스쪽으로 Redirect 되도록 수정한다
SeongHo5356 Jan 17, 2026
1677d6c
[SRLT-132] Refactor: 코드래빗 리뷰를 반영한다
SeongHo5356 Jan 19, 2026
b70d0a1
[SRLT-132] Refactor: 코드래빗 리뷰를 반영한다
SeongHo5356 Jan 19, 2026
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
2 changes: 1 addition & 1 deletion config
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;
}

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
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 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=java

Repository: 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.java

Repository: StartUpLight/STARLIGHT_BE

Length of output: 5715


@eventlistener는 기본적으로 동기 실행이라 로그 저장 예외가 메일 전송 흐름을 차단할 수 있음

현재 구현은 동기 @EventListener를 사용하고 있어 logCommandPort.save(log)에서 예외가 발생하면 caller로 직접 전파되어 메일 전송을 실패로 만들 수 있습니다.

같은 프로젝트의 FeedbackRequestEventListener에서 보이는 패턴을 따라 다음을 적용해주세요:

  • @Async("emailTaskExecutor") 추가로 비동기 실행
  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 추가로 트랜잭션 커밋 후 실행
  • 예외 처리 추가하여 로깅 실패가 메일 전송을 영향주지 않도록 격리
🤖 Prompt for AI Agents
In
`@src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java`
around lines 16 - 26, The current BackofficeMailSendEventHandler.handle uses a
synchronous `@EventListener` and directly calls logCommandPort.save(log), which
can propagate exceptions back to the mail flow; change the handler to run
asynchronously and after transaction commit by annotating the handler with
`@Async`("emailTaskExecutor") and `@TransactionalEventListener`(phase =
TransactionPhase.AFTER_COMMIT) (replace or supplement the existing
`@EventListener`), and wrap the call to logCommandPort.save(log) in a try-catch
that logs any exception (without rethrowing) to ensure failures during
BackofficeMailSendLog.create/ logCommandPort.save do not affect mail sending;
reference symbols: BackofficeMailSendEvent,
BackofficeMailSendLogEventHandler.handle, BackofficeMailSendLog.create, and
logCommandPort.save.

}
}
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
));
}
}
Loading