diff --git a/starter/studio-application-starter-mail/README.md b/starter/studio-application-starter-mail/README.md index 7fe5901b..d536369a 100644 --- a/starter/studio-application-starter-mail/README.md +++ b/starter/studio-application-starter-mail/README.md @@ -17,6 +17,8 @@ studio: web: enabled: true base-path: /api/mgmt/mail + sse: true # SSE stream 노출 여부. 미지정 시 true + notify: sse # 작업 완료 알림 전송 채널: sse|stomp persistence: type: jpa # 글로벌 기본값 (mail.persistence 미설정 시) mail: @@ -34,6 +36,8 @@ studio: IMAP 계정/서버 설정은 `studio.mail.imap.*`를 기본으로 두고, 기존 `studio.features.mail.imap.*`는 transition fallback으로만 사용한다. +`studio.features.mail.web.sse`는 `/sync/stream` 엔드포인트 노출 여부만 제어한다. `studio.features.mail.web.notify=stomp`로 STOMP 알림을 쓰더라도 `sse=false`를 명시하지 않으면 SSE stream endpoint는 계속 노출되어 기존 클라이언트가 안정적으로 연결할 수 있다. + ## REST 엔드포인트 (기본 base-path: `/api/mgmt/mail`) - `GET /{mailId}`: 메일 + 첨부 메타 조회 (권한 `features:mail/read`) - `GET /` : 메일 목록 페이지 조회(`page`,`size`) (권한 `features:mail/read`) diff --git a/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailAutoConfiguration.java b/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailAutoConfiguration.java index 05472021..f4a1216a 100644 --- a/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailAutoConfiguration.java +++ b/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailAutoConfiguration.java @@ -2,6 +2,8 @@ import jakarta.persistence.EntityManagerFactory; +import java.util.List; + import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -25,6 +27,7 @@ import studio.one.application.mail.persistence.repository.MailAttachmentRepository; import studio.one.application.mail.persistence.repository.MailMessageRepository; import studio.one.application.mail.persistence.repository.MailSyncLogRepository; +import studio.one.application.mail.service.CompositeMailSyncNotifier; import studio.one.application.mail.service.MailAttachmentService; import studio.one.application.mail.service.MailMessageService; import studio.one.application.mail.service.MailSyncJobLauncher; @@ -157,14 +160,15 @@ public MailSyncService mailSyncService(ImapProperties imap, @ConditionalOnMissingBean public MailSyncJobLauncher mailSyncJobLauncher(MailSyncService mailSyncService, MailSyncLogService mailSyncLogService, - MailSyncNotifier mailSyncNotifier) { - return new MailSyncJobLauncher(mailSyncService, mailSyncLogService, mailSyncNotifier); + ObjectProvider mailSyncNotifiers) { + List notifiers = mailSyncNotifiers.orderedStream().toList(); + return new MailSyncJobLauncher(mailSyncService, mailSyncLogService, new CompositeMailSyncNotifier(notifiers)); } @Bean - @ConditionalOnMissingBean + @ConditionalOnMissingBean(SseMailSyncNotifier.class) @org.springframework.context.annotation.Conditional(MailSseCondition.class) - public SseMailSyncNotifier mailSyncNotifier() { + public SseMailSyncNotifier sseMailSyncNotifier() { return new SseMailSyncNotifier(); } @@ -195,10 +199,6 @@ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) if (sse != null) { return Boolean.parseBoolean(sse); } - String notify = env.getProperty(prefix + "notify"); - if (notify != null) { - return "sse".equalsIgnoreCase(notify); - } return true; } } diff --git a/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailStompNotifierAutoConfiguration.java b/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailStompNotifierAutoConfiguration.java index 5d7581e2..96bb943f 100644 --- a/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailStompNotifierAutoConfiguration.java +++ b/starter/studio-application-starter-mail/src/main/java/studio/one/application/mail/autoconfigure/MailStompNotifierAutoConfiguration.java @@ -12,7 +12,6 @@ import org.springframework.core.env.Environment; import org.springframework.core.type.AnnotatedTypeMetadata; -import studio.one.application.mail.service.MailSyncNotifier; import studio.one.application.mail.service.StompMailSyncNotifier; import studio.one.platform.constant.PropertyKeys; @@ -23,9 +22,9 @@ public class MailStompNotifierAutoConfiguration { @Bean - @ConditionalOnMissingBean(MailSyncNotifier.class) + @ConditionalOnMissingBean(StompMailSyncNotifier.class) @org.springframework.context.annotation.Conditional(MailStompCondition.class) - public MailSyncNotifier mailSyncNotifier( + public StompMailSyncNotifier stompMailSyncNotifier( ObjectProvider messagingServiceProvider, MailFeatureProperties properties) { studio.one.platform.realtime.stomp.messaging.RealtimeMessagingService messagingService = messagingServiceProvider.getIfAvailable(); @@ -42,14 +41,14 @@ static class MailStompCondition implements Condition { public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { Environment env = context.getEnvironment(); String prefix = PropertyKeys.Features.PREFIX + ".mail.web."; - String sse = env.getProperty(prefix + "sse"); - if (sse != null) { - return !Boolean.parseBoolean(sse); - } String notify = env.getProperty(prefix + "notify"); if (notify != null) { return "stomp".equalsIgnoreCase(notify); } + String sse = env.getProperty(prefix + "sse"); + if (sse != null) { + return !Boolean.parseBoolean(sse); + } return false; } } diff --git a/starter/studio-application-starter-mail/src/test/java/studio/one/application/mail/autoconfigure/MailNotifierConditionTest.java b/starter/studio-application-starter-mail/src/test/java/studio/one/application/mail/autoconfigure/MailNotifierConditionTest.java new file mode 100644 index 00000000..2cbc3bc6 --- /dev/null +++ b/starter/studio-application-starter-mail/src/test/java/studio/one/application/mail/autoconfigure/MailNotifierConditionTest.java @@ -0,0 +1,79 @@ +package studio.one.application.mail.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.env.Environment; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.mock.env.MockEnvironment; + +class MailNotifierConditionTest { + + private final AnnotationMetadata metadata = AnnotationMetadata.introspect(MailNotifierConditionTest.class); + + @Test + void sseEndpointIsEnabledByDefaultEvenWhenNotifyTransportIsStomp() { + MockEnvironment environment = new MockEnvironment() + .withProperty("studio.features.mail.web.notify", "stomp"); + + boolean matches = new MailAutoConfiguration.MailSseCondition() + .matches(new TestConditionContext(environment), metadata); + + assertThat(matches).isTrue(); + } + + @Test + void sseEndpointCanBeDisabledExplicitly() { + MockEnvironment environment = new MockEnvironment() + .withProperty("studio.features.mail.web.notify", "stomp") + .withProperty("studio.features.mail.web.sse", "false"); + + boolean matches = new MailAutoConfiguration.MailSseCondition() + .matches(new TestConditionContext(environment), metadata); + + assertThat(matches).isFalse(); + } + + @Test + void stompNotifierIsEnabledByNotifyTransportEvenWhenSseEndpointIsEnabled() { + MockEnvironment environment = new MockEnvironment() + .withProperty("studio.features.mail.web.notify", "stomp") + .withProperty("studio.features.mail.web.sse", "true"); + + boolean matches = new MailStompNotifierAutoConfiguration.MailStompCondition() + .matches(new TestConditionContext(environment), metadata); + + assertThat(matches).isTrue(); + } + + private record TestConditionContext(Environment environment) implements ConditionContext { + + @Override + public org.springframework.beans.factory.support.BeanDefinitionRegistry getRegistry() { + return null; + } + + @Override + public ConfigurableListableBeanFactory getBeanFactory() { + return null; + } + + @Override + public Environment getEnvironment() { + return environment; + } + + @Override + public ResourceLoader getResourceLoader() { + return null; + } + + @Override + public ClassLoader getClassLoader() { + return getClass().getClassLoader(); + } + } +} diff --git a/studio-application-modules/mail-service/README.md b/studio-application-modules/mail-service/README.md index 08ab9e0f..dde272a1 100644 --- a/studio-application-modules/mail-service/README.md +++ b/studio-application-modules/mail-service/README.md @@ -20,6 +20,8 @@ studio: web: enabled: true base-path: /api/mgmt/mail + sse: true # /sync/stream 노출 여부. 미지정 시 true + notify: sse # 작업 완료 알림 전송 채널: sse|stomp persistence: type: jpa # jpa | jdbc (mail.persistence 미설정 시 사용) mail: @@ -45,6 +47,8 @@ studio: 5. 동시 실행 방지: 이미 동기화 중이면 `error.mail.sync.in-progress`(409) 응답이 반환된다. 6. 중복 UID(고유 제약)나 파싱 오류가 있는 메일/파트는 건너뛰고 실패 건수에만 반영된다(작업은 계속 진행). +`studio.features.mail.web.notify=stomp`는 작업 완료 이벤트의 전송 채널을 추가/선택하는 설정이며, SSE endpoint 노출과는 별개다. `/sync/stream`을 닫으려면 `studio.features.mail.web.sse=false`를 명시한다. + ### Vue 예시: 동기화 요청 + SSE 수신 ```ts // axios 및 EventSource 사용 예 diff --git a/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/CompositeMailSyncNotifier.java b/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/CompositeMailSyncNotifier.java new file mode 100644 index 00000000..7e72a7f3 --- /dev/null +++ b/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/CompositeMailSyncNotifier.java @@ -0,0 +1,27 @@ +package studio.one.application.mail.service; + +import java.util.List; + +import lombok.extern.slf4j.Slf4j; +import studio.one.application.mail.web.dto.MailSyncLogDto; + +@Slf4j +public class CompositeMailSyncNotifier implements MailSyncNotifier { + + private final List notifiers; + + public CompositeMailSyncNotifier(List notifiers) { + this.notifiers = List.copyOf(notifiers); + } + + @Override + public void notifyLog(MailSyncLogDto dto) { + for (MailSyncNotifier notifier : notifiers) { + try { + notifier.notifyLog(dto); + } catch (Exception ex) { + log.debug("Mail sync notifier {} failed: {}", notifier.getClass().getName(), ex.getMessage()); + } + } + } +} diff --git a/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/SseMailSyncNotifier.java b/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/SseMailSyncNotifier.java index 6dfc0dfa..45e5b19c 100644 --- a/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/SseMailSyncNotifier.java +++ b/studio-application-modules/mail-service/src/main/java/studio/one/application/mail/service/SseMailSyncNotifier.java @@ -24,6 +24,7 @@ public SseEmitter register(long timeoutMillis) { emitter.onError(e -> emitters.remove(emitter)); log.info("[SSE] Mail sync listener registered. total={}", emitters.size()); + sendConnectedEvent(emitter); return emitter; } @@ -47,4 +48,16 @@ public void notifyLog(MailSyncLogDto dto) { log.info("[SSE] Mail sync event sent. alive={}, dead={}", emitters.size(), dead.size()); } + + private void sendConnectedEvent(SseEmitter emitter) { + try { + emitter.send(SseEmitter.event() + .name("connected") + .data("mail-sync")); + } catch (Exception e) { + emitters.remove(emitter); + emitter.complete(); + log.debug("Failed to send initial mail sync SSE event: {}", e.getMessage()); + } + } } diff --git a/studio-application-modules/mail-service/src/test/java/studio/one/application/mail/service/CompositeMailSyncNotifierTest.java b/studio-application-modules/mail-service/src/test/java/studio/one/application/mail/service/CompositeMailSyncNotifierTest.java new file mode 100644 index 00000000..8469ab16 --- /dev/null +++ b/studio-application-modules/mail-service/src/test/java/studio/one/application/mail/service/CompositeMailSyncNotifierTest.java @@ -0,0 +1,36 @@ +package studio.one.application.mail.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import studio.one.application.mail.web.dto.MailSyncLogDto; + +class CompositeMailSyncNotifierTest { + + @Test + void notifyLogContinuesWhenOneNotifierFails() { + MailSyncNotifier failing = dto -> { + throw new IllegalStateException("boom"); + }; + MailSyncNotifier succeeding = mock(MailSyncNotifier.class); + CompositeMailSyncNotifier notifier = new CompositeMailSyncNotifier(List.of(failing, succeeding)); + MailSyncLogDto dto = MailSyncLogDto.builder().logId(1L).status("SUCCEEDED").build(); + + assertThatCode(() -> notifier.notifyLog(dto)).doesNotThrowAnyException(); + + verify(succeeding).notifyLog(dto); + } + + @Test + void notifyLogAllowsEmptyNotifierList() { + CompositeMailSyncNotifier notifier = new CompositeMailSyncNotifier(List.of()); + MailSyncLogDto dto = MailSyncLogDto.builder().logId(1L).status("SUCCEEDED").build(); + + assertThatCode(() -> notifier.notifyLog(dto)).doesNotThrowAnyException(); + } +}