From 720144a168d86abfddb00f8585c2aede5818eb09 Mon Sep 17 00:00:00 2001 From: vimal-tech-dev Date: Wed, 3 Jun 2026 02:18:02 +0530 Subject: [PATCH] Implement production-grade email retry optimization Add terminal retry state and Flyway migration support Signed-off-by: vimal-tech-dev --- docker-compose.yml | 2 +- .../contactapi/config/SchedulerConfig.java | 9 ++ .../vimaltech/contactapi/entity/EmailLog.java | 51 +++++++++ .../contactapi/enums/EmailStatus.java | 10 ++ .../repository/EmailLogRepository.java | 17 +++ .../service/EmailRetryScheduler.java | 88 ++++++++++++++ .../contactapi/service/EmailService.java | 3 + .../service/impl/DevEmailService.java | 59 ++++++++++ .../service/impl/NoOpEmailService.java | 6 + .../service/impl/SmtpEmailService.java | 107 ++++++++++++++++-- src/main/resources/application-dev.yml | 4 +- .../V3__create_email_logs_indexes.sql | 2 + .../migration/V4__create_email_logs_table.sql | 22 ++++ ...5__update_email_logs_status_constraint.sql | 14 +++ 14 files changed, 382 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/vimaltech/contactapi/config/SchedulerConfig.java create mode 100644 src/main/java/com/vimaltech/contactapi/entity/EmailLog.java create mode 100644 src/main/java/com/vimaltech/contactapi/enums/EmailStatus.java create mode 100644 src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java create mode 100644 src/main/java/com/vimaltech/contactapi/service/EmailRetryScheduler.java create mode 100644 src/main/java/com/vimaltech/contactapi/service/impl/DevEmailService.java create mode 100644 src/main/resources/db/migration/V3__create_email_logs_indexes.sql create mode 100644 src/main/resources/db/migration/V4__create_email_logs_table.sql create mode 100644 src/main/resources/db/migration/V5__update_email_logs_status_constraint.sql diff --git a/docker-compose.yml b/docker-compose.yml index fb5cd4f..35f6314 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,7 +29,7 @@ services: ports: - "8080:8080" env_file: - - .env.prod + - .env.dev environment: SPRING_PROFILES_ACTIVE: prod restart: unless-stopped diff --git a/src/main/java/com/vimaltech/contactapi/config/SchedulerConfig.java b/src/main/java/com/vimaltech/contactapi/config/SchedulerConfig.java new file mode 100644 index 0000000..2e919da --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package com.vimaltech.contactapi.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { +} diff --git a/src/main/java/com/vimaltech/contactapi/entity/EmailLog.java b/src/main/java/com/vimaltech/contactapi/entity/EmailLog.java new file mode 100644 index 0000000..dfae3dd --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/entity/EmailLog.java @@ -0,0 +1,51 @@ +package com.vimaltech.contactapi.entity; + +import com.vimaltech.contactapi.enums.EmailStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "email_logs") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EmailLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String toEmail; + + @Column(nullable = false) + private String subject; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private EmailStatus status; + + @Column(nullable = false) + private int retryCount; + + @Column(length = 1000) + private String lastError; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "last_attempt_at") + private LocalDateTime lastAttemptAt; + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/enums/EmailStatus.java b/src/main/java/com/vimaltech/contactapi/enums/EmailStatus.java new file mode 100644 index 0000000..854091c --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/enums/EmailStatus.java @@ -0,0 +1,10 @@ +package com.vimaltech.contactapi.enums; + +public enum EmailStatus { + + PENDING, + SENT, + FAILED, + IN_PROGRESS, + FAILED_PERMANENT +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java b/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java new file mode 100644 index 0000000..b5bd63b --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/repository/EmailLogRepository.java @@ -0,0 +1,17 @@ +package com.vimaltech.contactapi.repository; + +import com.vimaltech.contactapi.entity.EmailLog; +import com.vimaltech.contactapi.enums.EmailStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EmailLogRepository + extends JpaRepository { + + List findTop10ByStatusOrderByCreatedAtAsc( + EmailStatus status + ); +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/EmailRetryScheduler.java b/src/main/java/com/vimaltech/contactapi/service/EmailRetryScheduler.java new file mode 100644 index 0000000..1297504 --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/service/EmailRetryScheduler.java @@ -0,0 +1,88 @@ +package com.vimaltech.contactapi.service; + +import com.vimaltech.contactapi.entity.EmailLog; +import com.vimaltech.contactapi.enums.EmailStatus; +import com.vimaltech.contactapi.repository.EmailLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EmailRetryScheduler { + + private static final int MAX_RETRIES = 3; + + private final EmailLogRepository emailLogRepository; + private final EmailService emailService; + + @Scheduled(fixedDelay = 60000) + public void retryFailedEmails() { + + List failedEmails = + emailLogRepository + .findTop10ByStatusOrderByCreatedAtAsc( + EmailStatus.FAILED + ); + + if (failedEmails.isEmpty()) { + return; + } + + log.info( + "Retry scheduler found {} failed emails", + failedEmails.size() + ); + + for (EmailLog emailLog : failedEmails) { + + /* + * Prevent infinite retry polling + */ + if (emailLog.getRetryCount() >= MAX_RETRIES) { + + emailLog.setStatus( + EmailStatus.FAILED_PERMANENT + ); + + emailLog.setLastError( + "Retry limit exhausted" + ); + + emailLogRepository.save(emailLog); + + log.error( + "Email permanently failed after {} retries. Email ID={}", + emailLog.getRetryCount(), + emailLog.getId() + ); + + continue; + } + + /* + * Prevent retry spam within 1 minute + */ + if (emailLog.getLastAttemptAt() != null + && emailLog.getLastAttemptAt() + .isAfter(LocalDateTime.now().minusMinutes(1))) { + + continue; + } + + log.warn( + "Retrying email attempt {}/{} for email id={}", + emailLog.getRetryCount() + 1, + MAX_RETRIES, + emailLog.getId() + ); + + emailService.retryEmail(emailLog); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/EmailService.java b/src/main/java/com/vimaltech/contactapi/service/EmailService.java index 39d7fa3..a9ab3a5 100644 --- a/src/main/java/com/vimaltech/contactapi/service/EmailService.java +++ b/src/main/java/com/vimaltech/contactapi/service/EmailService.java @@ -1,7 +1,10 @@ package com.vimaltech.contactapi.service; import com.vimaltech.contactapi.dto.EmailRequest; +import com.vimaltech.contactapi.entity.EmailLog; public interface EmailService { void sendEmail(EmailRequest request); + + void retryEmail(EmailLog emailLog); } \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/impl/DevEmailService.java b/src/main/java/com/vimaltech/contactapi/service/impl/DevEmailService.java new file mode 100644 index 0000000..8386d73 --- /dev/null +++ b/src/main/java/com/vimaltech/contactapi/service/impl/DevEmailService.java @@ -0,0 +1,59 @@ +package com.vimaltech.contactapi.service.impl; + +import com.vimaltech.contactapi.dto.EmailRequest; +import com.vimaltech.contactapi.entity.EmailLog; +import com.vimaltech.contactapi.service.EmailService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +@Service +@Profile("dev") +@Slf4j +public class DevEmailService implements EmailService { + + @Override + public void sendEmail(EmailRequest request) { + + log.info(""" + + ============================== + DEV EMAIL SIMULATION + ============================== + TO: {} + SUBJECT: {} + + BODY: + {} + ============================== + """, + request.getTo(), + request.getSubject(), + request.getBody() + ); + } + + @Override + public void retryEmail(EmailLog emailLog) { + + log.info(""" + + ============================== + DEV EMAIL RETRY + ============================== + TO: {} + SUBJECT: {} + + BODY: + {} + + RETRY COUNT: {} + ============================== + """, + emailLog.getToEmail(), + emailLog.getSubject(), + emailLog.getBody(), + emailLog.getRetryCount() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/impl/NoOpEmailService.java b/src/main/java/com/vimaltech/contactapi/service/impl/NoOpEmailService.java index 4a0fd64..aa5cf82 100644 --- a/src/main/java/com/vimaltech/contactapi/service/impl/NoOpEmailService.java +++ b/src/main/java/com/vimaltech/contactapi/service/impl/NoOpEmailService.java @@ -1,6 +1,7 @@ package com.vimaltech.contactapi.service.impl; import com.vimaltech.contactapi.dto.EmailRequest; +import com.vimaltech.contactapi.entity.EmailLog; import com.vimaltech.contactapi.service.EmailService; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Profile; @@ -15,4 +16,9 @@ public class NoOpEmailService implements EmailService { public void sendEmail(EmailRequest request) { log.warn("Email disabled (NoOp) | to={}", request.getTo()); } + + @Override + public void retryEmail(EmailLog emailLog) { + + } } \ No newline at end of file diff --git a/src/main/java/com/vimaltech/contactapi/service/impl/SmtpEmailService.java b/src/main/java/com/vimaltech/contactapi/service/impl/SmtpEmailService.java index 6c48115..626f902 100644 --- a/src/main/java/com/vimaltech/contactapi/service/impl/SmtpEmailService.java +++ b/src/main/java/com/vimaltech/contactapi/service/impl/SmtpEmailService.java @@ -1,6 +1,9 @@ package com.vimaltech.contactapi.service.impl; import com.vimaltech.contactapi.dto.EmailRequest; +import com.vimaltech.contactapi.entity.EmailLog; +import com.vimaltech.contactapi.enums.EmailStatus; +import com.vimaltech.contactapi.repository.EmailLogRepository; import com.vimaltech.contactapi.service.EmailService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -11,6 +14,8 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; + @Service @Profile("prod") @Slf4j @@ -19,41 +24,125 @@ public class SmtpEmailService implements EmailService { private final JavaMailSender mailSender; private final String from; + private final EmailLogRepository emailLogRepository; + private static final int MAX_RETRIES = 3; public SmtpEmailService( JavaMailSender mailSender, - @Value("${app.mail.from}") String from + @Value("${app.mail.from}") String from, + EmailLogRepository emailLogRepository ) { this.mailSender = mailSender; this.from = from; + this.emailLogRepository = emailLogRepository; } @Override @Async("emailExecutor") public void sendEmail(EmailRequest request) { + + EmailLog emailLog = EmailLog.builder() + .toEmail(request.getTo()) + .subject(request.getSubject()) + .body(request.getBody()) + .status(EmailStatus.PENDING) + .retryCount(0) + .build(); + + emailLog = emailLogRepository.save(emailLog); + + processEmailSend(emailLog, request); + } + + private void processEmailSend( + EmailLog emailLog, + EmailRequest request + ) { + try { + log.info("START: Sending email | to={} | thread={}", - request.getTo(), Thread.currentThread().getName()); - + request.getTo(), + Thread.currentThread().getName()); + + emailLog.setStatus(EmailStatus.IN_PROGRESS); + emailLog.setLastAttemptAt(LocalDateTime.now()); + + emailLogRepository.save(emailLog); + SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(from); // 🔥 CRITICAL FIX + message.setFrom(from); message.setTo(request.getTo()); message.setSubject(request.getSubject()); message.setText(request.getBody()); - // ✅ OPTIONAL but IMPORTANT - if (request.getReplyTo() != null && !request.getReplyTo().isBlank()) { + if (request.getReplyTo() != null + && !request.getReplyTo().isBlank()) { + message.setReplyTo(request.getReplyTo().trim()); } mailSender.send(message); - log.info("SUCCESS: Email sent | to={}", request.getTo()); + emailLog.setStatus(EmailStatus.SENT); + + log.info("SUCCESS: Email sent | to={}", + request.getTo()); } catch (Exception e) { - // ❗ DO NOT THROW in async - log.error("ERROR: Email failed | to={}", request.getTo(), e); + + int updatedRetryCount = + emailLog.getRetryCount() + 1; + + emailLog.setRetryCount(updatedRetryCount); + + emailLog.setLastError(e.getMessage()); + + /* + * Move to terminal failure state + * after max retries exhausted + */ + if (updatedRetryCount >= MAX_RETRIES) { + + emailLog.setStatus( + EmailStatus.FAILED_PERMANENT + ); + + log.error( + "PERMANENT FAILURE: Email exhausted retries | to={}", + request.getTo(), + e + ); + + } else { + + emailLog.setStatus(EmailStatus.FAILED); + + log.warn( + "RETRY FAILURE: Email send failed | retryCount={} | to={}", + updatedRetryCount, + request.getTo(), + e + ); + } } + + emailLog.setLastAttemptAt(LocalDateTime.now()); + + emailLogRepository.save(emailLog); + } + + @Override + @Async("emailExecutor") + public void retryEmail(EmailLog emailLog) { + + EmailRequest request = EmailRequest.builder() + .to(emailLog.getToEmail()) + .subject(emailLog.getSubject()) + .body(emailLog.getBody()) + .build(); + + processEmailSend(emailLog, request); } } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 372a4e4..b496c43 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -24,7 +24,7 @@ logging: level: root: INFO com.vimaltech.contactapi: DEBUG - org.hibernate.SQL: DEBUG - org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.SQL: INFO + org.hibernate.orm.jdbc.bind: INFO org.springframework.web.servlet.resource: WARN org.springframework.web.servlet.PageNotFound: WARN \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__create_email_logs_indexes.sql b/src/main/resources/db/migration/V3__create_email_logs_indexes.sql new file mode 100644 index 0000000..81efb90 --- /dev/null +++ b/src/main/resources/db/migration/V3__create_email_logs_indexes.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_email_logs_status +ON email_logs(status); \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__create_email_logs_table.sql b/src/main/resources/db/migration/V4__create_email_logs_table.sql new file mode 100644 index 0000000..ca18352 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_email_logs_table.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS email_logs ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + + to_email VARCHAR(255) NOT NULL, + + subject VARCHAR(255) NOT NULL, + + body TEXT NOT NULL, + + status VARCHAR(255) NOT NULL, + + retry_count INTEGER NOT NULL, + + last_error VARCHAR(1000), + + created_at TIMESTAMP NOT NULL, + + last_attempt_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_email_logs_status +ON email_logs(status); \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__update_email_logs_status_constraint.sql b/src/main/resources/db/migration/V5__update_email_logs_status_constraint.sql new file mode 100644 index 0000000..328a544 --- /dev/null +++ b/src/main/resources/db/migration/V5__update_email_logs_status_constraint.sql @@ -0,0 +1,14 @@ +ALTER TABLE email_logs +DROP CONSTRAINT IF EXISTS email_logs_status_check; + +ALTER TABLE email_logs +ADD CONSTRAINT email_logs_status_check +CHECK ( + status IN ( + 'PENDING', + 'SENT', + 'FAILED', + 'IN_PROGRESS', + 'FAILED_PERMANENT' + ) +); \ No newline at end of file