Scheduled Email Triggers & Nurture Sequence Architecture

📬 Series: Email Infrastructure with AWS SES

  1. Overview, SMTP Setup, DNS & Marketing
  2. Multi-Tenant FROM Address Resolution & Spring EmailService
  3. SNS Bounce & Complaint Webhook Handling
  4. You are here — Scheduled Email Triggers & Nurture Sequence Architecture

Transactional emails respond to what users do. Marketing nurture emails respond to what users haven’t done yet — or guide them through a journey based on where they are in the funnel. This post covers designing the trigger system that powers both, with a particular focus on scheduled sends and the conditional logic that makes nurture sequences effective rather than just noisy.


Two Types of Email Triggers

Every email your application sends originates from one of two trigger types:

Event-driven triggers fire immediately in response to a user action. They are synchronous from the application’s perspective — the event happens, the email goes out:

Trigger Template Timing
Appointment booked BOOKING_CONFIRMATION Immediately
User signs up SIGNUP_VERIFY Immediately
Password reset requested PASSWORD_RESET Immediately
Payment received PAYMENT_RECEIPT Immediately

Scheduled triggers run on a clock rather than a user event. They require a periodic job that evaluates which users meet a condition and sends accordingly:

Trigger Template Timing
Appointment tomorrow APPOINTMENT_REMINDER 24h before
No-show detected NO_SHOW_FOLLOWUP 2h after missed appt
Weekly report WEEKLY_SUMMARY Sunday 6 PM per tenant TZ
Inactive user (7 days) REENGAGEMENT_D7 Day 7 of inactivity
Inactive user (14 days) REENGAGEMENT_D14 Day 14 of inactivity

Both types flow through the same EmailService — the difference is only in what initiates the call.


Event-Driven Triggers

The cleanest implementation is a Spring application event. The trigger publishes an event; a listener calls EmailService. This decouples business logic from email logic entirely:

// In your booking service
@Service
@RequiredArgsConstructor
public class BookingService {

    private final ApplicationEventPublisher eventPublisher;

    public Booking createBooking(CreateBookingRequest request) {
        Booking booking = // ... persist booking
        eventPublisher.publishEvent(new BookingCreatedEvent(this, booking));
        return booking;
    }
}
// In your email listener
@Component
@RequiredArgsConstructor
public class BookingEmailListener {

    private final EmailService emailService;

    @EventListener
    @Async
    public void onBookingCreated(BookingCreatedEvent event) {
        Booking booking = event.getBooking();
        emailService.sendEmail(EmailRequest.builder()
            .tenantId(booking.getTenantId())
            .recipientEmail(booking.getClientEmail())
            .templateType(EmailTemplateType.BOOKING_CONFIRMATION)
            .templateData(Map.of(
                "first_name", booking.getClientFirstName(),
                "booking_date", formatDate(booking.getStartTime()),
                "booking_time", formatTime(booking.getStartTime()),
                "business_name", booking.getTenantName()
            ))
            .build());
    }
}

Using @Async on the listener means the email send never blocks the booking transaction. The booking succeeds even if the email is delayed or temporarily fails.


Scheduled Triggers with Spring

Spring’s @Scheduled annotation handles periodic jobs. For email triggers, you typically run a job every few minutes that queries for records meeting a send condition:

@Component
@RequiredArgsConstructor
@Slf4j
public class AppointmentReminderScheduler {

    private final AppointmentRepository appointmentRepo;
    private final EmailService emailService;

    @Scheduled(cron = "0 */5 * * * *") // every 5 minutes
    public void sendAppointmentReminders() {
        LocalDateTime windowStart = LocalDateTime.now().plusHours(23).plusMinutes(55);
        LocalDateTime windowEnd   = LocalDateTime.now().plusHours(24).plusMinutes(5);

        List<Appointment> upcoming = appointmentRepo
            .findConfirmedInWindowNotYetNotified(windowStart, windowEnd);

        for (Appointment appt : upcoming) {
            try {
                emailService.sendEmail(buildReminderRequest(appt));
                appointmentRepo.markReminderSent(appt.getId());
            } catch (Exception e) {
                log.error("Failed to send reminder for appointment {}", appt.getId(), e);
                // Continue processing others — don't let one failure stop the batch
            }
        }
    }
}

Idempotency Is Critical

The markReminderSent call is essential. Without it, every scheduler run will find the same upcoming appointments and send duplicate reminders. Use a boolean flag or timestamp column (reminder_sent_at) on the appointment record, and filter it in your query:

SELECT * FROM appointments
WHERE status = 'CONFIRMED'
  AND start_time BETWEEN :windowStart AND :windowEnd
  AND reminder_sent_at IS NULL

For distributed deployments (multiple instances), use a database-level advisory lock or a distributed lock (Redis SETNX) around the scheduler to prevent two instances from processing the same appointments simultaneously.


Designing a Nurture Sequence

A nurture sequence is a series of emails sent to a contact over time, each contingent on the contact not having converted (or not having met some other exit condition). The architecture requires:

  1. Sequence definition — what emails to send and when
  2. Enrollment — adding a contact to a sequence when they meet a trigger condition
  3. Step processor — a scheduled job that advances contacts through their sequences
  4. Exit evaluation — checking whether a contact should stop receiving the sequence

Sequence Definition

Define sequences in configuration or a database table:

public enum NurtureSequence {
    POST_SIGNUP(List.of(
        new SequenceStep(Duration.ZERO,        EmailTemplateType.WELCOME),
        new SequenceStep(Duration.ofDays(2),   EmailTemplateType.GETTING_STARTED),
        new SequenceStep(Duration.ofDays(7),   EmailTemplateType.REENGAGEMENT_D7),
        new SequenceStep(Duration.ofDays(14),  EmailTemplateType.SOCIAL_PROOF),
        new SequenceStep(Duration.ofDays(21),  EmailTemplateType.OFFER)
    ));

    private final List<SequenceStep> steps;
}

Enrollment Table

CREATE TABLE nurture_enrollments (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    contact_id      BIGINT NOT NULL,
    tenant_id       VARCHAR(100) NOT NULL,
    sequence        VARCHAR(100) NOT NULL,
    current_step    INT NOT NULL DEFAULT 0,
    enrolled_at     TIMESTAMP NOT NULL,
    next_send_at    TIMESTAMP NOT NULL,
    completed_at    TIMESTAMP,
    exited_at       TIMESTAMP,
    exit_reason     VARCHAR(100),
    INDEX idx_next_send (next_send_at, completed_at, exited_at)
);

Step Processor

@Scheduled(cron = "0 0 * * * *") // every hour
public void processNurtureSteps() {
    List<NurtureEnrollment> due = enrollmentRepo
        .findDueForSending(Instant.now());

    for (NurtureEnrollment enrollment : due) {
        try {
            processStep(enrollment);
        } catch (Exception e) {
            log.error("Failed nurture step for enrollment {}", enrollment.getId(), e);
        }
    }
}

private void processStep(NurtureEnrollment enrollment) {
    NurtureSequence sequence = NurtureSequence.valueOf(enrollment.getSequence());
    List<SequenceStep> steps = sequence.getSteps();
    int stepIndex = enrollment.getCurrentStep();

    if (stepIndex >= steps.size()) {
        enrollmentRepo.markCompleted(enrollment.getId());
        return;
    }

    // Evaluate exit conditions before sending
    if (shouldExit(enrollment)) {
        enrollmentRepo.markExited(enrollment.getId(), exitReason(enrollment));
        return;
    }

    SequenceStep step = steps.get(stepIndex);
    emailService.sendEmail(buildNurtureRequest(enrollment, step));

    // Advance to next step
    if (stepIndex + 1 < steps.size()) {
        Instant nextSend = Instant.now().plus(steps.get(stepIndex + 1).getDelay());
        enrollmentRepo.advanceStep(enrollment.getId(), stepIndex + 1, nextSend);
    } else {
        enrollmentRepo.markCompleted(enrollment.getId());
    }
}

Conditional State Evaluation

The shouldExit check is what separates a smart nurture sequence from an email blast. Before sending each step, evaluate the contact’s current state:

private boolean shouldExit(NurtureEnrollment enrollment) {
    Contact contact = contactRepo.findById(enrollment.getContactId());

    // Exit if they've converted (booked an appointment)
    if (contact.getFirstBookingAt() != null) return true;

    // Exit if they've unsubscribed from marketing email
    if (!contact.isMarketingEmailOptIn()) return true;

    // Exit if address is suppressed (bounced or complained)
    if (suppressionRepo.isSuppressed(contact.getEmail())) return true;

    // Exit if they're already an active client (past the nurture stage)
    if (contact.getStatus() == ContactStatus.ACTIVE_CLIENT) return true;

    return false;
}

This ensures that a user who books an appointment on day 3 doesn’t receive the day-7 “still interested?” re-engagement email. The sequence simply exits cleanly, and the booking confirmation flow takes over.


Suppression and Exit Conditions

Beyond the per-contact state checks, apply these sequence-level rules:

Unsubscribe handling: Every marketing email must include an unsubscribe link. When a contact clicks it, set marketing_email_opt_in = false on their record. The shouldExit check above will catch this on the next step.

Global suppression check: Always cross-reference the suppression list (from Part 3) before sending any nurture step. An address that hard-bounced should never receive another email, regardless of sequence state.

Frequency cap: Avoid sending multiple emails on the same day from different sequences. A contact might be enrolled in a post-signup sequence and also receive an appointment reminder on the same day — implement a daily cap:

private boolean exceedsDailyCap(Contact contact) {
    int sentToday = emailLogRepo.countSentToday(contact.getEmail());
    return sentToday >= maxEmailsPerDay; // e.g. 1 or 2
}

Warming Up a New Sending Domain

If you’re launching a new domain or IP address for marketing sends, do not blast your full list immediately. Inbox providers observe a new sender’s volume and gradually extend trust. Ramping too quickly signals spam behavior.

A conservative warmup schedule:

Week Daily volume Who to send to
1 50–200 Most engaged (opened/clicked in 90 days)
2 500–1,000 Engaged (opened in 180 days)
3 2,000–5,000 All opted-in contacts
4+ Full volume Maintain based on engagement

Wire the warmup schedule into your enrollment processor by adding a configurable volume cap that increases on a schedule. Store the current warmup phase in config or a feature flag that you advance manually (or automate based on elapsed days).


Observability

Production nurture sequences need visibility into what’s happening:

Email send log table: Record every email sent, with template type, tenant, recipient (hashed or pseudonymized for privacy), timestamp, and the enrollment/step that triggered it.

CREATE TABLE email_send_log (
    id              BIGINT AUTO_INCREMENT PRIMARY KEY,
    tenant_id       VARCHAR(100),
    recipient_hash  VARCHAR(64),  -- SHA-256 of email
    template_type   VARCHAR(100),
    enrollment_id   BIGINT,
    step_index      INT,
    sent_at         TIMESTAMP,
    ses_message_id  VARCHAR(255)  -- for correlating with bounce/complaint events
);

Storing the SES messageId (returned from the send call) is particularly valuable: when a bounce notification arrives, it includes the original messageId, letting you trace exactly which send triggered the feedback.

Metrics to track:

  • Emails sent per sequence per day
  • Step completion rates (what % reach step 3 vs drop out at step 1)
  • Exit reason distribution (converted vs unsubscribed vs bounced)
  • Sequence-level conversion rate (enrolled → booked)

These metrics tell you not just whether your infrastructure is working, but whether your nurture content is actually moving people through the funnel.


Wrapping Up the Series

This post completes the four-part series on building a production email infrastructure with AWS SES:

  1. Part 1: Overview, SMTP Setup, DNS & Marketing — the foundations: SMTP config, SPF/DKIM/DMARC, and what the architecture looks like
  2. Part 2: Multi-Tenant FROM Address Resolution — the Spring EmailService and TemplateRenderer that power per-tenant sending
  3. Part 3: SNS Bounce & Complaint Webhook Handling — protecting your sending reputation with automated suppression
  4. Part 4 (this post) — the scheduler and conditional logic that powers event-driven and nurture email

Together these four pieces give you an email system that’s reliable, reputation-safe, multi-tenant-capable, and ready to power marketing at scale.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • Layer 5 — Spring Security: Role-Based Authorization and CORS
  • Layer 4 — API Key Authentication: Securing Machine-to-Machine Requests
  • Layer 2 — Tenant Resolution: How a Single API Instance Serves Multiple Customers Safely
  • Layer 1 — nginx as Your Security Perimeter: SSL Termination and Access Logging
  • Setting Up Transactional & Marketing Email with AWS SES