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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -88,13 +79,7 @@ public void sendMail(MailRequest mailRequest, UUID requesterId, boolean isAdmin)
Map<String, String> 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);
}
}

Expand Down Expand Up @@ -157,16 +142,6 @@ private static String escapeHtml(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +42 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Catch Spring's MailException to properly track sending failures.

mailSender.send(message) throws Spring's MailException (an unchecked RuntimeException), not the checked MessagingException (which is only thrown by MimeMessageHelper setup methods). By only catching MessagingException, actual delivery failures (like connection refused or SMTP timeouts) will bypass this catch block. This will cause the failure metric to be missed, and the exception will be swallowed by Spring's async executor.

Catch Exception (or add a catch block for MailException) to ensure all delivery failures are correctly logged and metered.

🐛 Proposed fix
-        } catch (MessagingException e) {
+        } catch (Exception e) {
             meterRegistry.counter("letters_sent_total", "status", "failure").increment();
             LOG.error("Failed to send mail to {}", to, e);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (MessagingException e) {
meterRegistry.counter("letters_sent_total", "status", "failure").increment();
LOG.error("Failed to send mail to {}", to, e);
}
} catch (Exception e) {
meterRegistry.counter("letters_sent_total", "status", "failure").increment();
LOG.error("Failed to send mail to {}", to, e);
}
🧰 Tools
🪛 PMD (7.26.0)

[Low] 44-44: InvalidLogMessageFormat (Error Prone): Too many arguments, expected 1 argument but found 2

(InvalidLogMessageFormat (Error Prone))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@services/spring-letter/src/main/java/tum/devoops/letterservice/service/MailDispatcher.java`
around lines 42 - 45, Update the exception handling in MailDispatcher’s
mail-sending flow to catch Spring MailException, or a broader Exception, so
runtime delivery failures from mailSender.send(message) increment the failure
counter and are logged through the existing LOG.error call. Preserve the current
failure metric and logging behavior for MessagingException as well.

}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -259,20 +260,19 @@ 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);

MemberEntity frank = member("Frank", "Foster", "frank@example.com");
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);
}

Expand Down
Loading