Skip to content

Commit f907e23

Browse files
Merge pull request #51 from vimal-tech-dev/feature/email-retry-phase-11.7
Implement production-grade email retry optimization
2 parents c16626d + 720144a commit f907e23

14 files changed

Lines changed: 382 additions & 12 deletions

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ services:
2929
ports:
3030
- "8080:8080"
3131
env_file:
32-
- .env.prod
32+
- .env.dev
3333
environment:
3434
SPRING_PROFILES_ACTIVE: prod
3535
restart: unless-stopped
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.vimaltech.contactapi.config;
2+
3+
import org.springframework.context.annotation.Configuration;
4+
import org.springframework.scheduling.annotation.EnableScheduling;
5+
6+
@Configuration
7+
@EnableScheduling
8+
public class SchedulerConfig {
9+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.vimaltech.contactapi.entity;
2+
3+
import com.vimaltech.contactapi.enums.EmailStatus;
4+
import jakarta.persistence.*;
5+
import lombok.*;
6+
7+
import java.time.LocalDateTime;
8+
9+
@Entity
10+
@Table(name = "email_logs")
11+
@Getter
12+
@Setter
13+
@NoArgsConstructor
14+
@AllArgsConstructor
15+
@Builder
16+
public class EmailLog {
17+
18+
@Id
19+
@GeneratedValue(strategy = GenerationType.IDENTITY)
20+
private Long id;
21+
22+
@Column(nullable = false)
23+
private String toEmail;
24+
25+
@Column(nullable = false)
26+
private String subject;
27+
28+
@Column(nullable = false, columnDefinition = "TEXT")
29+
private String body;
30+
31+
@Enumerated(EnumType.STRING)
32+
@Column(nullable = false)
33+
private EmailStatus status;
34+
35+
@Column(nullable = false)
36+
private int retryCount;
37+
38+
@Column(length = 1000)
39+
private String lastError;
40+
41+
@Column(name = "created_at", nullable = false, updatable = false)
42+
private LocalDateTime createdAt;
43+
44+
@Column(name = "last_attempt_at")
45+
private LocalDateTime lastAttemptAt;
46+
47+
@PrePersist
48+
public void prePersist() {
49+
this.createdAt = LocalDateTime.now();
50+
}
51+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.vimaltech.contactapi.enums;
2+
3+
public enum EmailStatus {
4+
5+
PENDING,
6+
SENT,
7+
FAILED,
8+
IN_PROGRESS,
9+
FAILED_PERMANENT
10+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.vimaltech.contactapi.repository;
2+
3+
import com.vimaltech.contactapi.entity.EmailLog;
4+
import com.vimaltech.contactapi.enums.EmailStatus;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
import java.util.List;
9+
10+
@Repository
11+
public interface EmailLogRepository
12+
extends JpaRepository<EmailLog, Long> {
13+
14+
List<EmailLog> findTop10ByStatusOrderByCreatedAtAsc(
15+
EmailStatus status
16+
);
17+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package com.vimaltech.contactapi.service;
2+
3+
import com.vimaltech.contactapi.entity.EmailLog;
4+
import com.vimaltech.contactapi.enums.EmailStatus;
5+
import com.vimaltech.contactapi.repository.EmailLogRepository;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Service;
10+
11+
import java.time.LocalDateTime;
12+
import java.util.List;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
@Slf4j
17+
public class EmailRetryScheduler {
18+
19+
private static final int MAX_RETRIES = 3;
20+
21+
private final EmailLogRepository emailLogRepository;
22+
private final EmailService emailService;
23+
24+
@Scheduled(fixedDelay = 60000)
25+
public void retryFailedEmails() {
26+
27+
List<EmailLog> failedEmails =
28+
emailLogRepository
29+
.findTop10ByStatusOrderByCreatedAtAsc(
30+
EmailStatus.FAILED
31+
);
32+
33+
if (failedEmails.isEmpty()) {
34+
return;
35+
}
36+
37+
log.info(
38+
"Retry scheduler found {} failed emails",
39+
failedEmails.size()
40+
);
41+
42+
for (EmailLog emailLog : failedEmails) {
43+
44+
/*
45+
* Prevent infinite retry polling
46+
*/
47+
if (emailLog.getRetryCount() >= MAX_RETRIES) {
48+
49+
emailLog.setStatus(
50+
EmailStatus.FAILED_PERMANENT
51+
);
52+
53+
emailLog.setLastError(
54+
"Retry limit exhausted"
55+
);
56+
57+
emailLogRepository.save(emailLog);
58+
59+
log.error(
60+
"Email permanently failed after {} retries. Email ID={}",
61+
emailLog.getRetryCount(),
62+
emailLog.getId()
63+
);
64+
65+
continue;
66+
}
67+
68+
/*
69+
* Prevent retry spam within 1 minute
70+
*/
71+
if (emailLog.getLastAttemptAt() != null
72+
&& emailLog.getLastAttemptAt()
73+
.isAfter(LocalDateTime.now().minusMinutes(1))) {
74+
75+
continue;
76+
}
77+
78+
log.warn(
79+
"Retrying email attempt {}/{} for email id={}",
80+
emailLog.getRetryCount() + 1,
81+
MAX_RETRIES,
82+
emailLog.getId()
83+
);
84+
85+
emailService.retryEmail(emailLog);
86+
}
87+
}
88+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.vimaltech.contactapi.service;
22

33
import com.vimaltech.contactapi.dto.EmailRequest;
4+
import com.vimaltech.contactapi.entity.EmailLog;
45

56
public interface EmailService {
67
void sendEmail(EmailRequest request);
8+
9+
void retryEmail(EmailLog emailLog);
710
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.vimaltech.contactapi.service.impl;
2+
3+
import com.vimaltech.contactapi.dto.EmailRequest;
4+
import com.vimaltech.contactapi.entity.EmailLog;
5+
import com.vimaltech.contactapi.service.EmailService;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.context.annotation.Profile;
8+
import org.springframework.stereotype.Service;
9+
10+
@Service
11+
@Profile("dev")
12+
@Slf4j
13+
public class DevEmailService implements EmailService {
14+
15+
@Override
16+
public void sendEmail(EmailRequest request) {
17+
18+
log.info("""
19+
20+
==============================
21+
DEV EMAIL SIMULATION
22+
==============================
23+
TO: {}
24+
SUBJECT: {}
25+
26+
BODY:
27+
{}
28+
==============================
29+
""",
30+
request.getTo(),
31+
request.getSubject(),
32+
request.getBody()
33+
);
34+
}
35+
36+
@Override
37+
public void retryEmail(EmailLog emailLog) {
38+
39+
log.info("""
40+
41+
==============================
42+
DEV EMAIL RETRY
43+
==============================
44+
TO: {}
45+
SUBJECT: {}
46+
47+
BODY:
48+
{}
49+
50+
RETRY COUNT: {}
51+
==============================
52+
""",
53+
emailLog.getToEmail(),
54+
emailLog.getSubject(),
55+
emailLog.getBody(),
56+
emailLog.getRetryCount()
57+
);
58+
}
59+
}

src/main/java/com/vimaltech/contactapi/service/impl/NoOpEmailService.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.vimaltech.contactapi.service.impl;
22

33
import com.vimaltech.contactapi.dto.EmailRequest;
4+
import com.vimaltech.contactapi.entity.EmailLog;
45
import com.vimaltech.contactapi.service.EmailService;
56
import lombok.extern.slf4j.Slf4j;
67
import org.springframework.context.annotation.Profile;
@@ -15,4 +16,9 @@ public class NoOpEmailService implements EmailService {
1516
public void sendEmail(EmailRequest request) {
1617
log.warn("Email disabled (NoOp) | to={}", request.getTo());
1718
}
19+
20+
@Override
21+
public void retryEmail(EmailLog emailLog) {
22+
23+
}
1824
}

0 commit comments

Comments
 (0)