Email notifications with outbox pattern#
Never send email inline from a handler hook — it blocks the HTTP response, fails silently on SMTP errors, and can’t retry. Instead, write a log row with status PENDING and let a @Scheduled background job process the queue.
This page covers the full pattern: the log entity, writing from handler hooks, the email service, the scheduled processor, and other useful scheduled jobs that follow the same shape.
Module layout#
Keep scheduled jobs in a dedicated Gradle module (or package) so they share JPA entities and services with handlers but don’t pollute the handler classpath.
service/
your-scheduler/
build.gradle ← depends on your-jpa and your-services
src/main/java/
EmailQueueProcessor.java
SnapshotScheduler.java
OrphanedFileCleanup.javaEnable scheduling in the app:
@SpringBootApplication
@Import(PalmyraSpringConfiguration.class)
@EnableScheduling
public class AppMain { /* ... */ }The email log entity#
@Entity
@Table(name = "email_log")
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class EmailLogEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) private String toAddress;
@Column(nullable = false) private String subject;
@Column(columnDefinition = "TEXT") private String body;
private String attachmentPath;
@Column(nullable = false, length = 16)
private String status; // PENDING, SENT, FAILED
private Instant createdAt;
private Instant sentAt;
@Column(length = 512) private String errorMessage;
}Writing the log from a handler hook#
The handler writes a PENDING row — the HTTP response returns immediately. The background job picks it up later.
@Component
@CrudMapping(mapping = "/request", type = RequestModel.class,
secondaryMapping = "/request/{id}")
public class RequestHandler extends AbstractHandler
implements QueryHandler, ReadHandler, SaveHandler {
@Autowired private EmailLogRepo emailLogRepo;
@Override
public Tuple postCreate(Tuple tuple, HandlerContext ctx) {
emailLogRepo.save(EmailLogEntity.builder()
.toAddress((String) tuple.get("applicantEmail"))
.subject("Your request #" + tuple.get("requestNumber") + " has been submitted")
.body(buildEmailBody(tuple))
.status("PENDING")
.createdAt(Instant.now())
.build());
return tuple;
}
private String buildEmailBody(Tuple tuple) {
return "Dear " + tuple.get("applicantName") + ",\n\n"
+ "Your request has been received. Reference: " + tuple.get("requestNumber") + ".\n\n"
+ "You will receive updates as it progresses.";
}
}Email service#
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
@Value("${app.email.enabled:false}")
private boolean enabled;
@Value("${app.email.from:noreply@example.com}")
private String fromAddress;
public void send(String to, String subject, String body, String attachmentPath)
throws MessagingException, IOException {
if (!enabled) {
log.info("Email suppressed (disabled): to={}, subject={}", to, subject);
return;
}
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, attachmentPath != null);
helper.setFrom(fromAddress);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(body);
if (attachmentPath != null) {
File file = new File(attachmentPath);
if (file.exists()) helper.addAttachment(file.getName(), file);
}
mailSender.send(message);
}
}Configuration#
app:
email:
enabled: true # false in dev / staging
from: noreply@yourapp.com
spring:
mail:
host: smtp.example.com
port: 587
username: ${SMTP_USER}
password: ${SMTP_PASS}
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: trueThe scheduled email processor#
Runs every 5 minutes, reads PENDING rows, sends them, and updates status to SENT or FAILED with error messages.
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailQueueProcessor {
private final EmailLogRepo emailLogRepo;
private final EmailService emailService;
@Scheduled(fixedRate = 5 * 60 * 1000) // every 5 minutes
@Transactional
public void processPendingEmails() {
List<EmailLogEntity> pending = emailLogRepo.findByStatus("PENDING");
for (var entry : pending) {
try {
emailService.send(entry.getToAddress(), entry.getSubject(),
entry.getBody(), entry.getAttachmentPath());
entry.setStatus("SENT");
entry.setSentAt(Instant.now());
} catch (Exception e) {
entry.setStatus("FAILED");
entry.setErrorMessage(e.getMessage());
log.error("Email send failed for log {}: {}", entry.getId(), e.getMessage());
}
emailLogRepo.save(entry);
}
if (!pending.isEmpty()) {
log.info("Processed {} email(s): sent={}, failed={}",
pending.size(),
pending.stream().filter(e -> "SENT".equals(e.getStatus())).count(),
pending.stream().filter(e -> "FAILED".equals(e.getStatus())).count());
}
}
}Email on different lifecycle events#
| Event | Hook | Typical email |
|---|---|---|
| New record created | postCreate |
“Your request has been submitted” |
| Record approved | Custom controller approve() |
“Your request has been approved” |
| Record rejected | Custom controller reject() |
“Your request requires corrections” |
| Payment received | Payment callback handler | “Payment confirmed — receipt attached” |
| SLA breach | Scheduled job | “Request #123 is overdue — escalation required” |
In every case, the handler or controller writes a PENDING log row; the background job handles the delivery.
Other useful scheduled jobs#
The same @Scheduled + dedicated-module pattern applies to any background work.
Periodic snapshot for dashboards#
Capture a point-in-time count per department — useful for trend charts:
@Service
@RequiredArgsConstructor
public class DepartmentSnapshotScheduler {
private final DepartmentRepo departmentRepo;
private final SnapshotRepo snapshotRepo;
@Scheduled(cron = "0 0 1 1,15 * *") // 1 AM on the 1st and 15th
@Transactional
public void captureSnapshot() {
List<Object[]> counts = departmentRepo.countRequestsByDepartment();
Instant now = Instant.now();
for (Object[] row : counts) {
snapshotRepo.save(SnapshotEntity.builder()
.departmentId((Long) row[0])
.pendingCount(((Number) row[1]).intValue())
.approvedCount(((Number) row[2]).intValue())
.rejectedCount(((Number) row[3]).intValue())
.capturedAt(now)
.build());
}
}
}Orphaned file cleanup#
Delete upload temp files that were never attached to an entity:
@Service
@RequiredArgsConstructor
public class OrphanedFileCleanup {
private final FileRepo fileRepo;
@Scheduled(cron = "0 0 2 * * *") // 2 AM daily
@Transactional
public void cleanup() {
Instant cutoff = Instant.now().minus(24, ChronoUnit.HOURS);
int deleted = fileRepo.deleteOrphanedBefore(cutoff);
if (deleted > 0) log.info("Cleaned up {} orphaned files", deleted);
}
}Guidelines#
- Always use the outbox for email. Even “just one email” should go through the queue — it makes the handler fast, the email retriable, and the history queryable.
- Add an
enabledflag. Suppressing email in dev/staging prevents accidental sends to real users. - Attach PDFs via path. Generate the PDF to a temp file in
postCreate/approve(); record the path in the log; the queue job attaches it and cleans up after send. - Monitor the FAILED rows. A simple dashboard query (
SELECT COUNT(*) FROM email_log WHERE status = 'FAILED') alerts ops when SMTP is misconfigured. - Isolate jobs in their own module. Scheduled jobs don’t need handler interfaces on their classpath. A dedicated Gradle module keeps the boundary clean.
- Log outcomes. Every scheduled run should log how many items it processed and how many failed — silent background jobs are the hardest to debug in production.
- Guard against overlap. For long-running jobs, use
@Scheduled(fixedDelay = ...)(waits for completion) instead offixedRate(can overlap).
See also: Approval workflow, Cross-entity validation.