Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {

/**
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/weatherviewer/config/AppConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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-"));
}

}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
2 changes: 2 additions & 0 deletions src/main/java/com/weatherviewer/mapper/UserMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand Down
12 changes: 10 additions & 2 deletions src/main/java/com/weatherviewer/service/MailService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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 {

Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
* <p>
* Two things happen here that matter for reliability:
* <ul>
* <li>{@link TransactionalEventListener} with
* {@link TransactionPhase#AFTER_COMMIT} defers the send until the
* transaction that created the token has actually committed. If that
* transaction rolls back for any reason, the event is discarded and
* no email goes out — previously, the mail call sat inside the same
* {@code @Transactional} method as the token write, so a caller could
* (in principle) receive a link for a token that was never persisted.</li>
* <li>{@link Async} runs the send on a dedicated pool
* ({@code mailTaskExecutor}, see {@code AppConfig}) rather than the
* request thread, so a slow or unreachable SMTP server can no longer
* add latency to sign-up, email verification, or password-reset
* requests.</li>
* </ul>
* {@link MailService} itself still retries transient SMTP failures and
* never throws, so a failure here is logged by it directly and does not
* propagate any further.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class MailEventListener {

private final MailService mailService;

@Async("mailTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onVerificationEmailRequested(VerificationEmailRequestedEvent event) {
log.debug("Dispatching verification email to {}", event.email());
mailService.sendVerificationEmail(event.email(), event.firstName(), event.verificationLink());
}

@Async("mailTaskExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPasswordResetEmailRequested(PasswordResetEmailRequestedEvent event) {
log.debug("Dispatching password reset email to {}", event.email());
mailService.sendPasswordResetEmail(event.email(), event.firstName(), event.resetLink());
}

}
46 changes: 35 additions & 11 deletions src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.weatherviewer.service.impl;

import com.weatherviewer.service.MailService;
import io.github.resilience4j.retry.Retry;
import io.github.resilience4j.retry.RetryRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
Expand All @@ -12,21 +14,34 @@
* {@link MailService} implementation backed by {@link JavaMailSender}.
* <p>
* 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.
* <p>
* 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;
}

Expand Down Expand Up @@ -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());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
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;
import com.weatherviewer.model.enums.TokenType;
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;
Expand All @@ -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.
* <p>
* 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
Expand All @@ -37,22 +46,22 @@ 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;
private final long passwordResetTtlHours;

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;
Expand All @@ -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
Expand All @@ -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));
}

Expand Down
8 changes: 8 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}

}
Loading