Skip to content
Draft
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
29 changes: 29 additions & 0 deletions src/main/java/org/tailormap/api/configuration/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2025 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/

package org.tailormap.api.configuration;

import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

Check warning on line 17 in src/main/java/org/tailormap/api/configuration/AsyncConfig.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/AsyncConfig.java#L17

Added line #L17 was not covered by tests

@Bean(name = "passwordResetTaskExecutor")
public Executor passwordResetTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("pwd-reset-");
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The AsyncConfig should configure setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds to ensure graceful shutdown. Without this, pending password reset emails might not be sent when the application shuts down, potentially leaving users without the ability to reset their passwords. This is especially important given the queue capacity of 10.

Suggested change
executor.setThreadNamePrefix("pwd-reset-");
executor.setThreadNamePrefix("pwd-reset-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);

Copilot uses AI. Check for mistakes.
executor.initialize();
return executor;

Check warning on line 27 in src/main/java/org/tailormap/api/configuration/AsyncConfig.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/AsyncConfig.java#L21-L27

Added lines #L21 - L27 were not covered by tests
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* SPDX-License-Identifier: MIT
*/

package org.tailormap.api.controller;

import static org.tailormap.api.util.TMPasswordDeserializer.encoder;
Expand All @@ -21,20 +22,13 @@
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand All @@ -48,6 +42,7 @@
import org.tailormap.api.persistence.User;
import org.tailormap.api.repository.TemporaryTokenRepository;
import org.tailormap.api.repository.UserRepository;
import org.tailormap.api.service.PasswordResetEmailService;
import org.tailormap.api.viewer.model.ErrorResponse;

@RestController
Expand All @@ -56,14 +51,10 @@
public class PasswordResetController {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final JavaMailSender emailSender;
private final UserRepository userRepository;
private final TemporaryTokenRepository temporaryTokenRepository;
private final MessageSource messageSource;
private final LocaleResolver localeResolver;

@Value("${tailormap-api.mail.from}")
private String mailFrom;
private final PasswordResetEmailService passwordResetEmailService;

@Value("${tailormap-api.password-reset.enabled:false}")
private boolean passwordResetEnabled;
Expand All @@ -75,16 +66,14 @@
private int passwordResetTokenExpirationMinutes;

public PasswordResetController(
JavaMailSender emailSender,
UserRepository userRepository,
TemporaryTokenRepository temporaryTokenRepository,
MessageSource messageSource,
LocaleResolver localeResolver) {
this.emailSender = emailSender;
LocaleResolver localeResolver,
PasswordResetEmailService passwordResetEmailService) {

Check warning on line 72 in src/main/java/org/tailormap/api/controller/PasswordResetController.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/controller/PasswordResetController.java#L72

Added line #L72 was not covered by tests
this.userRepository = userRepository;
this.temporaryTokenRepository = temporaryTokenRepository;
this.messageSource = messageSource;
this.localeResolver = localeResolver;
this.passwordResetEmailService = passwordResetEmailService;

Check warning on line 76 in src/main/java/org/tailormap/api/controller/PasswordResetController.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/controller/PasswordResetController.java#L76

Added line #L76 was not covered by tests
}

@ExceptionHandler({ConstraintViolationException.class})
Expand Down Expand Up @@ -165,47 +154,12 @@
}

private void sendPasswordResetEmail(String email, HttpServletRequest request) {
final String absoluteLinkPrefix =
String absoluteLinkPrefix =

Check warning on line 157 in src/main/java/org/tailormap/api/controller/PasswordResetController.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/controller/PasswordResetController.java#L157

Added line #L157 was not covered by tests
request.getRequestURL().toString().replace(request.getRequestURI(), request.getContextPath());
final Locale locale = localeResolver.resolveLocale(request);

try (ExecutorService emailExecutor = Executors.newSingleThreadExecutor()) {
emailExecutor.execute(() -> {
try {
this.userRepository.findByEmail(email).ifPresent(user -> {
if (!user.isEnabledAndValidUntil()) return;

TemporaryToken token = new TemporaryToken(
TemporaryToken.TokenType.PASSWORD_RESET,
user.getUsername(),
passwordResetTokenExpirationMinutes);
token = temporaryTokenRepository.save(token);

String absoluteLink = absoluteLinkPrefix
+ /* this is the route in the angular application */ "/user/password-reset/"
+ token.getCombinedTokenAndExpirationAsBase64();

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(user.getEmail());
message.setSubject(
messageSource.getMessage("reset-password-request.email-subject", null, locale));
message.setText(messageSource.getMessage(
"reset-password-request.email-body", new Object[] {absoluteLink}, locale));

logger.trace("Sending message {}", message);
logger.info("Sending password reset email for user: {}", user.getUsername());
emailSender.send(message);
});
} catch (MailException e) {
logger.error("Failed to send password reset email", e);
} catch (Exception e) {
logger.error("Unexpected exception in password reset email thread", e);
}
});
emailExecutor.shutdown();
} catch (RejectedExecutionException e) {
logger.error("Failed to start password reset email thread", e);
}
Locale locale = localeResolver.resolveLocale(request);

Check warning on line 159 in src/main/java/org/tailormap/api/controller/PasswordResetController.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/controller/PasswordResetController.java#L159

Added line #L159 was not covered by tests

// Delegate to async service — returns immediately
passwordResetEmailService.sendPasswordResetEmailAsync(

Check warning on line 162 in src/main/java/org/tailormap/api/controller/PasswordResetController.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/controller/PasswordResetController.java#L162

Added line #L162 was not covered by tests
email, absoluteLinkPrefix, locale, passwordResetTokenExpirationMinutes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright (C) 2025 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/

package org.tailormap.api.service;

import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.tailormap.api.persistence.TemporaryToken;
import org.tailormap.api.persistence.User;
import org.tailormap.api.repository.TemporaryTokenRepository;
import org.tailormap.api.repository.UserRepository;

@Service
public class PasswordResetEmailService {

private static final Logger logger = LoggerFactory.getLogger(PasswordResetEmailService.class);

Check warning on line 26 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L26

Added line #L26 was not covered by tests

private final JavaMailSender emailSender;
private final UserRepository userRepository;
private final TemporaryTokenRepository temporaryTokenRepository;
private final MessageSource messageSource;

@Value("${tailormap-api.mail.from}")
private String mailFrom;

public PasswordResetEmailService(
JavaMailSender emailSender,
UserRepository userRepository,
TemporaryTokenRepository temporaryTokenRepository,
MessageSource messageSource) {
this.emailSender = emailSender;
this.userRepository = userRepository;
this.temporaryTokenRepository = temporaryTokenRepository;
this.messageSource = messageSource;
}

Check warning on line 45 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L40-L45

Added lines #L40 - L45 were not covered by tests

@Async("passwordResetTaskExecutor")
public void sendPasswordResetEmailAsync(
String email, String absoluteLinkPrefix, Locale locale, int tokenExpiryMinutes) {
try {
User user = userRepository.findByEmail(email).orElse(null);

Check warning on line 51 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L51

Added line #L51 was not covered by tests
if (user == null || !user.isEnabledAndValidUntil()) {
return;

Check warning on line 53 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L53

Added line #L53 was not covered by tests
}

TemporaryToken token =
new TemporaryToken(TemporaryToken.TokenType.PASSWORD_RESET, user.getUsername(), tokenExpiryMinutes);
token = temporaryTokenRepository.save(token);

Check warning on line 58 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L56-L58

Added lines #L56 - L58 were not covered by tests

String absoluteLink =
absoluteLinkPrefix + "/user/password-reset/" + token.getCombinedTokenAndExpirationAsBase64();

Check warning on line 61 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L60-L61

Added lines #L60 - L61 were not covered by tests

SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(user.getEmail());
message.setSubject(messageSource.getMessage("reset-password-request.email-subject", null, locale));
message.setText(
messageSource.getMessage("reset-password-request.email-body", new Object[] {absoluteLink}, locale));

Check warning on line 68 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L63-L68

Added lines #L63 - L68 were not covered by tests

logger.info("Sending password reset email for user: {}", user.getUsername());
emailSender.send(message); // blocking, but run in async thread
} catch (Exception e) {
logger.error("Failed to send password reset email", e);
}
Comment on lines +48 to +74
Copy link

Copilot AI Dec 11, 2025

Choose a reason for hiding this comment

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

The async method performs database operations (userRepository.findByEmail and temporaryTokenRepository.save) without @transactional annotation. When running in a separate thread, these operations may not have proper transaction boundaries, which could lead to connection leaks or unexpected behavior. Consider adding @transactional annotation to this method to ensure proper transaction management in the async context.

Copilot uses AI. Check for mistakes.
}

Check warning on line 75 in src/main/java/org/tailormap/api/service/PasswordResetEmailService.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/service/PasswordResetEmailService.java#L70-L75

Added lines #L70 - L75 were not covered by tests
}
Loading