Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions starter/studio-application-starter-mail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<MailSyncNotifier> mailSyncNotifiers) {
List<MailSyncNotifier> 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();
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<studio.one.platform.realtime.stomp.messaging.RealtimeMessagingService> messagingServiceProvider,
MailFeatureProperties properties) {
studio.one.platform.realtime.stomp.messaging.RealtimeMessagingService messagingService = messagingServiceProvider.getIfAvailable();
Expand All @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
4 changes: 4 additions & 0 deletions studio-application-modules/mail-service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 사용 예
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MailSyncNotifier> notifiers;

public CompositeMailSyncNotifier(List<MailSyncNotifier> 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());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading