diff --git a/.gitignore b/.gitignore index f0c9863c..5b69b651 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ AGENTS.md # Build-tool binaries — provided via the build's dependency management, never committed lombok-*.jar +services/py-genai-helper/.idea/ diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/LetterServiceApplication.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/LetterServiceApplication.java index 0e434824..8b1a4ac2 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/LetterServiceApplication.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/LetterServiceApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication +@EnableAsync public class LetterServiceApplication { public static void main(String[] args) { diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/config/AsyncConfig.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/config/AsyncConfig.java new file mode 100644 index 00000000..468f326a --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package tum.devoops.letterservice.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + @Override + @Bean(name = "taskExecutor") + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("mail-dispatch-"); + executor.initialize(); + return executor; + } +} diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java index 26266e5c..88452343 100644 --- a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/LetterService.java @@ -2,22 +2,16 @@ import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import io.micrometer.core.instrument.MeterRegistry; -import jakarta.mail.MessagingException; -import jakarta.mail.internet.MimeMessage; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Entities; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; -import org.springframework.mail.javamail.JavaMailSender; -import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import tum.devoops.letterservice.entity.MemberEntity; import tum.devoops.letterservice.entity.TeamEntity; import tum.devoops.letterservice.exception.ForbiddenException; -import tum.devoops.letterservice.exception.MailDeliveryException; import tum.devoops.letterservice.exception.PdfGenerationException; import tum.devoops.letterservice.model.MailRequest; import tum.devoops.letterservice.model.PdfRequest; @@ -47,8 +41,7 @@ public class LetterService { // Tokens are {{snake_case}} per the API description; anything else is left as literal text. private static final Pattern TAG_PATTERN = Pattern.compile("\\{\\{([a-z0-9_]+)\\}\\}"); - private final JavaMailSender mailSender; - private final String from; + private final MailDispatcher mailDispatcher; private final MemberRepository memberRepository; private final SportRepository sportRepository; private final TeamRepository teamRepository; @@ -58,8 +51,7 @@ public class LetterService { private final TransactionRepository transactionRepository; private final MeterRegistry meterRegistry; - public LetterService(JavaMailSender mailSender, - @Value("${spring.mail.username}") String from, + public LetterService(MailDispatcher mailDispatcher, MemberRepository memberRepository, SportRepository sportRepository, TeamRepository teamRepository, @@ -68,8 +60,7 @@ public LetterService(JavaMailSender mailSender, TraineeRepository traineeRepository, TransactionRepository transactionRepository, MeterRegistry meterRegistry) { - this.mailSender = mailSender; - this.from = from; + this.mailDispatcher = mailDispatcher; this.memberRepository = memberRepository; this.sportRepository = sportRepository; this.teamRepository = teamRepository; @@ -88,13 +79,7 @@ public void sendMail(MailRequest mailRequest, UUID requesterId, boolean isAdmin) Map tokens = tokensFor(receiver); String personalizedSubject = replaceTags(subject, tokens); String html = replaceTags(template, tokens); - try { - sendHtml(receiver.getEmail(), personalizedSubject, html); - meterRegistry.counter("letters_sent_total", "status", "success").increment(); - } catch (MessagingException e) { - meterRegistry.counter("letters_sent_total", "status", "failure").increment(); - throw new MailDeliveryException("Failed to send mail to " + receiver.getEmail(), e); - } + mailDispatcher.sendAsync(receiver.getEmail(), personalizedSubject, html); } } @@ -157,16 +142,6 @@ private static String escapeHtml(String value) { return value.replace("&", "&").replace("<", "<").replace(">", ">"); } - private void sendHtml(String to, String subject, String html) throws MessagingException { - MimeMessage message = mailSender.createMimeMessage(); - MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); - helper.setFrom(from); - helper.setTo(to); - helper.setSubject(subject); - helper.setText(html, true); - mailSender.send(message); - } - // Director/trainer/trainee aren't Spring Security roles here (see LetterController); membership // is looked up directly against the organization-schema rows, same pattern as // TransactionService.isDirectorOfMember/isTrainerOfMember and FeedbackService.assertTrainerOfMember. diff --git a/services/spring-letter/src/main/java/tum/devoops/letterservice/service/MailDispatcher.java b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/MailDispatcher.java new file mode 100644 index 00000000..0109152f --- /dev/null +++ b/services/spring-letter/src/main/java/tum/devoops/letterservice/service/MailDispatcher.java @@ -0,0 +1,47 @@ +package tum.devoops.letterservice.service; + +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +public class MailDispatcher { + + private static final Logger LOG = LoggerFactory.getLogger(MailDispatcher.class); + + private final JavaMailSender mailSender; + private final String from; + private final MeterRegistry meterRegistry; + + public MailDispatcher(JavaMailSender mailSender, + @Value("${spring.mail.username}") String from, + MeterRegistry meterRegistry) { + this.mailSender = mailSender; + this.from = from; + this.meterRegistry = meterRegistry; + } + + @Async + public void sendAsync(String to, String subject, String html) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setFrom(from); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(html, true); + mailSender.send(message); + meterRegistry.counter("letters_sent_total", "status", "success").increment(); + } catch (MessagingException e) { + meterRegistry.counter("letters_sent_total", "status", "failure").increment(); + LOG.error("Failed to send mail to {}", to, e); + } + } +} diff --git a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java index 329deef8..aea559e7 100644 --- a/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java +++ b/services/spring-letter/src/test/java/tum/devoops/letterservice/service/LetterServiceTest.java @@ -1,6 +1,7 @@ package tum.devoops.letterservice.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.times; @@ -35,7 +36,6 @@ import tum.devoops.letterservice.entity.TeamEntity; import tum.devoops.letterservice.entity.TransactionEntity; import tum.devoops.letterservice.exception.ForbiddenException; -import tum.devoops.letterservice.exception.MailDeliveryException; import tum.devoops.letterservice.model.MailRequest; import tum.devoops.letterservice.model.PdfRequest; import tum.devoops.letterservice.repository.DirectorRepository; @@ -74,7 +74,8 @@ class LetterServiceTest { @BeforeEach void setUp() { - letterService = new LetterService(mailSender, FROM, memberRepository, sportRepository, + MailDispatcher mailDispatcher = new MailDispatcher(mailSender, FROM, meterRegistry); + letterService = new LetterService(mailDispatcher, memberRepository, sportRepository, teamRepository, directorRepository, trainerRepository, traineeRepository, transactionRepository, meterRegistry); } @@ -259,8 +260,9 @@ void sendMailWithDirectorWithoutTeamShowsSportNameFromDirectorRole() throws Exce // --- sendMail: error handling --- @Test - void sendMailWrapsMessagingExceptionInMailDeliveryException() { - LetterService brokenFromService = new LetterService(mailSender, "not a valid from address", + void sendMailCountsFailureAndDoesNotPropagateWhenSendingThrows() { + MailDispatcher brokenDispatcher = new MailDispatcher(mailSender, "not a valid from address", meterRegistry); + LetterService brokenFromService = new LetterService(brokenDispatcher, memberRepository, sportRepository, teamRepository, directorRepository, trainerRepository, traineeRepository, transactionRepository, meterRegistry); @@ -268,11 +270,9 @@ void sendMailWrapsMessagingExceptionInMailDeliveryException() { when(memberRepository.findAll()).thenReturn(List.of(frank)); stubMimeMessages(); - assertThatThrownBy(() -> brokenFromService.sendMail(new MailRequest("Subject", "Body"), + assertThatCode(() -> brokenFromService.sendMail(new MailRequest("Subject", "Body"), UUID.randomUUID(), true)) - .isInstanceOf(MailDeliveryException.class) - .hasMessageContaining("frank@example.com") - .hasCauseInstanceOf(jakarta.mail.MessagingException.class); + .doesNotThrowAnyException(); assertThat(meterRegistry.counter("letters_sent_total", "status", "failure").count()).isEqualTo(1.0); }