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.java

Enable 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: true

The 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 enabled flag. 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 of fixedRate (can overlap).

See also: Approval workflow, Cross-entity validation.