diff --git a/src/main/java/com/weatherviewer/WeatherViewerApplication.java b/src/main/java/com/weatherviewer/WeatherViewerApplication.java index aff32cc..79f5158 100644 --- a/src/main/java/com/weatherviewer/WeatherViewerApplication.java +++ b/src/main/java/com/weatherviewer/WeatherViewerApplication.java @@ -3,6 +3,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.scheduling.annotation.EnableAsync; import java.util.TimeZone; @@ -12,10 +13,14 @@ * Bootstraps the application context and enables Spring's caching * abstraction ({@link EnableCaching}), which backs the weather/forecast/ * geocoding response caching in - * {@link com.weatherviewer.service.integration.WeatherApiCache}. + * {@link com.weatherviewer.service.integration.WeatherApiCache}, and + * Spring's {@code @Async} support ({@link EnableAsync}), which backs + * asynchronous mail dispatch in + * {@link com.weatherviewer.service.impl.MailEventListener}. */ @SpringBootApplication @EnableCaching +@EnableAsync public class WeatherViewerApplication { /** diff --git a/src/main/java/com/weatherviewer/config/AppConfig.java b/src/main/java/com/weatherviewer/config/AppConfig.java index 33df369..1cf5621 100644 --- a/src/main/java/com/weatherviewer/config/AppConfig.java +++ b/src/main/java/com/weatherviewer/config/AppConfig.java @@ -72,4 +72,22 @@ public ExecutorService weatherFetchExecutor( return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-")); } + /** + * Dedicated, bounded thread pool used by + * {@link com.weatherviewer.service.impl.MailEventListener} to send + * verification/password-reset emails off the request thread. + *
+ * Kept separate from {@link #weatherFetchExecutor} for the same reason + * that one is kept separate from the JVM-wide common pool: a burst of + * sign-ups or reset requests (each possibly retried a few times against + * a slow SMTP server) shouldn't be able to starve weather-dashboard + * fetches, or vice versa. Sized via {@code mail.async.pool-size} + * (default 5) and shut down automatically on context close. + */ + @Bean(name = "mailTaskExecutor", destroyMethod = "shutdown") + public ExecutorService mailTaskExecutor( + @Value("${mail.async.pool-size:5}") int poolSize) { + return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("mail-send-")); + } + } diff --git a/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java b/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java new file mode 100644 index 0000000..1f07d48 --- /dev/null +++ b/src/main/java/com/weatherviewer/event/PasswordResetEmailRequestedEvent.java @@ -0,0 +1,12 @@ +package com.weatherviewer.event; + +/** + * Published by {@link com.weatherviewer.service.impl.VerificationServiceImpl} + * once a password-reset token has been persisted. Consumed by + * {@link com.weatherviewer.service.impl.MailEventListener}, which sends the + * actual email asynchronously and only after the surrounding transaction + * commits — so a rolled-back reset request never results in an email + * pointing at a token that was never saved. + */ +public record PasswordResetEmailRequestedEvent(String email, String firstName, String resetLink) { +} diff --git a/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java b/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java new file mode 100644 index 0000000..d72edf3 --- /dev/null +++ b/src/main/java/com/weatherviewer/event/VerificationEmailRequestedEvent.java @@ -0,0 +1,12 @@ +package com.weatherviewer.event; + +/** + * Published by {@link com.weatherviewer.service.impl.VerificationServiceImpl} + * once a verification token has been persisted. Consumed by + * {@link com.weatherviewer.service.impl.MailEventListener}, which sends the + * actual email asynchronously and only after the surrounding transaction + * commits — so a rolled-back sign-up never results in an email pointing at + * a token that was never saved. + */ +public record VerificationEmailRequestedEvent(String email, String firstName, String verificationLink) { +} diff --git a/src/main/java/com/weatherviewer/mapper/UserMapper.java b/src/main/java/com/weatherviewer/mapper/UserMapper.java index 31bb030..4fa4781 100644 --- a/src/main/java/com/weatherviewer/mapper/UserMapper.java +++ b/src/main/java/com/weatherviewer/mapper/UserMapper.java @@ -28,6 +28,8 @@ public interface UserMapper { @Mapping(target = "createdAt", ignore = true) @Mapping(target = "units", ignore = true) @Mapping(target = "locations", ignore = true) + @Mapping(target = "lockedUntil", ignore = true) + @Mapping(target = "failedLoginAttempts", ignore = true) User fromRecord(CreateUserDto createUserDto); /** diff --git a/src/main/java/com/weatherviewer/service/MailService.java b/src/main/java/com/weatherviewer/service/MailService.java index 280e0cb..f168916 100644 --- a/src/main/java/com/weatherviewer/service/MailService.java +++ b/src/main/java/com/weatherviewer/service/MailService.java @@ -2,8 +2,16 @@ /** * Outbound transactional email. Implementations must never let a mail - * provider outage break the calling request (sign-up, password reset) — - * failures are logged and swallowed rather than propagated. + * provider outage break the calling request — failures are retried a few + * times, then logged and swallowed rather than propagated. + *
+ * In practice these methods are only ever invoked by + * {@link com.weatherviewer.service.impl.MailEventListener}, asynchronously + * and after the transaction that created the underlying token has + * committed (see {@link com.weatherviewer.event.VerificationEmailRequestedEvent} + * / {@link com.weatherviewer.event.PasswordResetEmailRequestedEvent}), so a + * slow or unreachable mail server never adds latency to — or breaks — the + * sign-up, verification, or password-reset request itself. */ public interface MailService { diff --git a/src/main/java/com/weatherviewer/service/impl/MailEventListener.java b/src/main/java/com/weatherviewer/service/impl/MailEventListener.java new file mode 100644 index 0000000..ee8403b --- /dev/null +++ b/src/main/java/com/weatherviewer/service/impl/MailEventListener.java @@ -0,0 +1,57 @@ +package com.weatherviewer.service.impl; + +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; +import com.weatherviewer.service.MailService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * Bridges {@code *EmailRequestedEvent}s raised by + * {@link VerificationServiceImpl} to actual sends via {@link MailService}. + *
+ * Two things happen here that matter for reliability: + *
* Emails are plain text on purpose (no external images/styles to fetch, - * nothing for a mail client to block). A misconfigured or unreachable SMTP - * server never bubbles up as a 500 to the user — sign-up and password - * reset both still succeed, they just log a warning that no email went - * out, so the account/token itself is never lost because of a mail outage. + * nothing for a mail client to block). Transient SMTP failures are retried + * via the {@code mail} Resilience4j retry instance (same pattern as the + * {@code weatherApi} retry around the OpenWeatherMap client) before giving + * up; a misconfigured or unreachable SMTP server never bubbles up past this + * class. Retry is applied programmatically here, rather than via + * {@code @Retry}, because the send happens on a private helper called from + * within this same bean — an annotation-based retry would be silently + * skipped by Spring AOP's proxy on that kind of self-invocation. + *
+ * Sending itself is now invoked off the request thread: {@link MailService} + * is only ever called by {@link MailEventListener}, asynchronously, after + * the transaction that created the underlying token has committed. That + * keeps a mail outage from adding latency to (or breaking) sign-up, + * verification, or password-reset requests. */ @Service @Slf4j public class MailServiceImpl implements MailService { private final JavaMailSender mailSender; + private final Retry retry; private final String fromAddress; public MailServiceImpl(JavaMailSender mailSender, + RetryRegistry retryRegistry, @Value("${app.mail.from:no-reply@weatherviewer.local}") String fromAddress) { this.mailSender = mailSender; + this.retry = retryRegistry.retry("mail"); this.fromAddress = fromAddress; } @@ -55,17 +70,26 @@ public void sendPasswordResetEmail(String to, String firstName, String resetLink send(to, subject, body); } + /** + * Sends the message, retrying transient SMTP failures via the {@code mail} + * Resilience4j retry instance. If every attempt fails, the failure is + * logged and swallowed here rather than propagated — callers (in + * practice, {@link MailEventListener} running on its own thread) never + * need to handle a mail-specific exception. + */ private void send(String to, String subject, String body) { try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(fromAddress); - message.setTo(to); - message.setSubject(subject); - message.setText(body); - mailSender.send(message); + retry.executeRunnable(() -> { + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(to); + message.setSubject(subject); + message.setText(body); + mailSender.send(message); + }); log.info("Sent email '{}' to {}", subject, to); } catch (MailException e) { - log.warn("Failed to send email '{}' to {}: {}", subject, to, e.getMessage()); + log.warn("Failed to send email '{}' to {} after retries: {}", subject, to, e.getMessage()); } } diff --git a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java index fc60ac5..cff158b 100644 --- a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java +++ b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java @@ -1,5 +1,7 @@ package com.weatherviewer.service.impl; +import com.weatherviewer.event.PasswordResetEmailRequestedEvent; +import com.weatherviewer.event.VerificationEmailRequestedEvent; import com.weatherviewer.exception.InvalidTokenException; import com.weatherviewer.model.User; import com.weatherviewer.model.VerificationToken; @@ -7,10 +9,10 @@ import com.weatherviewer.model.enums.UserStatus; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.repository.VerificationTokenRepository; -import com.weatherviewer.service.MailService; import com.weatherviewer.service.VerificationService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +30,13 @@ * emailed link. Issuing a new token for a given user/purpose first * invalidates any earlier unused ones of that same type, so only the most * recently emailed link ever works. + *
+ * The actual email is not sent from here. Instead, an
+ * {@code *EmailRequestedEvent} is published, which
+ * {@link MailEventListener} picks up asynchronously and only after this
+ * method's transaction has committed — so a rollback (or a slow/unreachable
+ * mail server) can never desync the token that was persisted from the link
+ * that was emailed.
*/
@Service
@Slf4j
@@ -37,7 +46,7 @@ public class VerificationServiceImpl implements VerificationService {
private final UserRepository userRepository;
private final VerificationTokenRepository tokenRepository;
- private final MailService mailService;
+ private final ApplicationEventPublisher eventPublisher;
private final PasswordEncoder passwordEncoder;
private final String baseUrl;
private final long verificationTtlHours;
@@ -45,14 +54,14 @@ public class VerificationServiceImpl implements VerificationService {
public VerificationServiceImpl(UserRepository userRepository,
VerificationTokenRepository tokenRepository,
- MailService mailService,
+ ApplicationEventPublisher eventPublisher,
PasswordEncoder passwordEncoder,
@Value("${app.base-url:http://localhost:8080}") String baseUrl,
@Value("${app.verification.token-ttl-hours:24}") long verificationTtlHours,
@Value("${app.password-reset.token-ttl-hours:1}") long passwordResetTtlHours) {
this.userRepository = userRepository;
this.tokenRepository = tokenRepository;
- this.mailService = mailService;
+ this.eventPublisher = eventPublisher;
this.passwordEncoder = passwordEncoder;
this.baseUrl = baseUrl;
this.verificationTtlHours = verificationTtlHours;
@@ -71,7 +80,7 @@ public void sendVerificationEmail(User user) {
.queryParam("token", rawToken)
.toUriString();
- mailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), link);
+ eventPublisher.publishEvent(new VerificationEmailRequestedEvent(user.getEmail(), user.getFirstName(), link));
}
@Override
@@ -98,7 +107,7 @@ public void requestPasswordReset(String email) {
.queryParam("token", rawToken)
.toUriString();
- mailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), link);
+ eventPublisher.publishEvent(new PasswordResetEmailRequestedEvent(user.getEmail(), user.getFirstName(), link));
}, () -> log.info("Password reset requested for unregistered email={}", email));
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 4cc8610..6d8610d 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -82,6 +82,12 @@ resilience4j.circuitbreaker.instances.weatherApi.wait-duration-in-open-state=30s
resilience4j.circuitbreaker.instances.weatherApi.permitted-number-of-calls-in-half-open-state=5
resilience4j.circuitbreaker.instances.weatherApi.automatic-transition-from-open-to-half-open-enabled=true
+# --- Resilience (retry around SMTP sends; see MailServiceImpl) ---
+resilience4j.retry.instances.mail.max-attempts=3
+resilience4j.retry.instances.mail.wait-duration=500ms
+resilience4j.retry.instances.mail.enable-exponential-backoff=true
+resilience4j.retry.instances.mail.exponential-backoff-multiplier=2
+
# --- Redis / caching ---
spring.data.redis.host=${SPRING_DATA_REDIS_HOST:localhost}
spring.data.redis.port=${SPRING_DATA_REDIS_PORT:6379}
@@ -100,6 +106,8 @@ spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.writetimeout=5000
app.mail.from=${MAIL_FROM:no-reply@weatherviewer.local}
+mail.async.pool-size=5
+
# --- Links embedded in emailed verification / reset tokens ---
app.base-url=${APP_BASE_URL:http://localhost:8080}
app.verification.token-ttl-hours=24
diff --git a/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java b/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java
new file mode 100644
index 0000000..d6fc3cf
--- /dev/null
+++ b/src/test/java/com/weatherviewer/service/impl/MailEventListenerTest.java
@@ -0,0 +1,54 @@
+package com.weatherviewer.service.impl;
+
+import com.weatherviewer.event.PasswordResetEmailRequestedEvent;
+import com.weatherviewer.event.VerificationEmailRequestedEvent;
+import com.weatherviewer.service.MailService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * These tests call the listener methods directly (as the {@code @Async} /
+ * {@code @TransactionalEventListener} machinery only applies through a live
+ * Spring context) — the behavior worth unit-testing here is simply "each
+ * event is unpacked into the right {@link MailService} call".
+ */
+@ExtendWith(MockitoExtension.class)
+class MailEventListenerTest {
+
+ @Mock
+ private MailService mailService;
+
+ @InjectMocks
+ private MailEventListener listener;
+
+ @Test
+ void onVerificationEmailRequested_delegatesToMailService() {
+ VerificationEmailRequestedEvent event = new VerificationEmailRequestedEvent(
+ "john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc");
+
+ listener.onVerificationEmailRequested(event);
+
+ verify(mailService).sendVerificationEmail(
+ "john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc");
+ verifyNoMoreInteractions(mailService);
+ }
+
+ @Test
+ void onPasswordResetEmailRequested_delegatesToMailService() {
+ PasswordResetEmailRequestedEvent event = new PasswordResetEmailRequestedEvent(
+ "jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz");
+
+ listener.onPasswordResetEmailRequested(event);
+
+ verify(mailService).sendPasswordResetEmail(
+ "jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz");
+ verifyNoMoreInteractions(mailService);
+ }
+
+}
diff --git a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java
index a7ed306..56bd0dd 100644
--- a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java
+++ b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java
@@ -1,5 +1,7 @@
package com.weatherviewer.service.impl;
+import io.github.resilience4j.retry.RetryConfig;
+import io.github.resilience4j.retry.RetryRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -11,6 +13,8 @@
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
+import java.time.Duration;
+
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
@@ -29,7 +33,12 @@ class MailServiceImplTest {
@BeforeEach
void setUp() {
- service = new MailServiceImpl(mailSender, FROM_ADDRESS);
+ RetryConfig fastRetryConfig = RetryConfig.custom()
+ .maxAttempts(2)
+ .waitDuration(Duration.ZERO)
+ .build();
+ RetryRegistry retryRegistry = RetryRegistry.of(fastRetryConfig);
+ service = new MailServiceImpl(mailSender, retryRegistry, FROM_ADDRESS);
}
@Test
diff --git a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java
index 147a062..171c40c 100644
--- a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java
+++ b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java
@@ -1,5 +1,7 @@
package com.weatherviewer.service.impl;
+import com.weatherviewer.event.PasswordResetEmailRequestedEvent;
+import com.weatherviewer.event.VerificationEmailRequestedEvent;
import com.weatherviewer.exception.InvalidTokenException;
import com.weatherviewer.model.User;
import com.weatherviewer.model.VerificationToken;
@@ -8,13 +10,13 @@
import com.weatherviewer.model.enums.UserStatus;
import com.weatherviewer.repository.UserRepository;
import com.weatherviewer.repository.VerificationTokenRepository;
-import com.weatherviewer.service.MailService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
@@ -23,7 +25,6 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@@ -40,7 +41,7 @@ class VerificationServiceImplTest {
private VerificationTokenRepository tokenRepository;
@Mock
- private MailService mailService;
+ private ApplicationEventPublisher eventPublisher;
@Mock
private PasswordEncoder passwordEncoder;
@@ -49,7 +50,7 @@ class VerificationServiceImplTest {
@BeforeEach
void setUp() {
- service = new VerificationServiceImpl(userRepository, tokenRepository, mailService, passwordEncoder,
+ service = new VerificationServiceImpl(userRepository, tokenRepository, eventPublisher, passwordEncoder,
BASE_URL, VERIFICATION_TTL_HOURS, PASSWORD_RESET_TTL_HOURS);
}
@@ -103,12 +104,15 @@ void sendVerificationEmail_sendsMailWithLinkContainingBaseUrlAndToken() {
verify(tokenRepository).save(tokenCaptor.capture());
String rawToken = tokenCaptor.getValue().getToken();
- ArgumentCaptor