Skip to content

Instantly share code, notes, and snippets.

@weytani
Created March 23, 2026 16:46
Show Gist options
  • Select an option

  • Save weytani/e1e0a3e90dcc08f468e9fb908e2bbb35 to your computer and use it in GitHub Desktop.

Select an option

Save weytani/e1e0a3e90dcc08f468e9fb908e2bbb35 to your computer and use it in GitHub Desktop.
Salesforce Case Escalation Email — Scheduled Apex Design (Sev-1: 15min, Sev-2: 30min, Sev-3: 12hr)

Case Escalation Email — Scheduled Apex Design

Architecture Overview

Case Created → Trigger sets Next_Escalation_Email__c = NOW() + 5min
                          ↓
Scheduled Apex (runs every 15min) → Queries cases where Next_Escalation_Email__c <= NOW()
                          ↓
                   Sends email → Updates Next_Escalation_Email__c to next interval
                          ↓
              Case Closed/Resolved → Trigger clears Next_Escalation_Email__c (stops emails)

Custom Fields on Case

Field API Name Type Purpose
Next Escalation Email Next_Escalation_Email__c DateTime When the next email should fire. Null = no email pending.
Last Escalation Email Last_Escalation_Email__c DateTime Audit trail — when the last email was sent.
Escalation Email Count Escalation_Email_Count__c Number How many escalation emails have been sent for this case.

Interval Logic

Severity Priority Value Interval Cron Coverage
Sev-1 High / 1 15 minutes Every scheduled run
Sev-2 Medium / 2 30 minutes Every other run
Sev-3 Low / 3 12 hours Twice daily

Component 1: Case Trigger (or After-Save Flow)

Sets Next_Escalation_Email__c on insert. Clears it when status becomes Closed/Resolved.

// ABOUTME: Trigger handler that manages escalation email scheduling on Case create/update.
// ABOUTME: Sets initial 5-min email on creation, clears on resolution.

public class CaseEscalationTriggerHandler {

    private static final Set<String> RESOLVED_STATUSES = new Set<String>{
        'Closed', 'Resolved'
    };

    public static void handleAfterInsert(List<Case> newCases) {
        List<Case> toUpdate = new List<Case>();
        for (Case c : newCases) {
            if (c.Priority != null && !RESOLVED_STATUSES.contains(c.Status)) {
                toUpdate.add(new Case(
                    Id = c.Id,
                    Next_Escalation_Email__c = DateTime.now().addMinutes(5)
                ));
            }
        }
        if (!toUpdate.isEmpty()) {
            update toUpdate;
        }
    }

    public static void handleAfterUpdate(List<Case> newCases, Map<Id, Case> oldMap) {
        List<Case> toUpdate = new List<Case>();
        for (Case c : newCases) {
            Case old = oldMap.get(c.Id);
            // Case just resolved — stop escalation
            if (RESOLVED_STATUSES.contains(c.Status) && !RESOLVED_STATUSES.contains(old.Status)) {
                toUpdate.add(new Case(
                    Id = c.Id,
                    Next_Escalation_Email__c = null
                ));
            }
            // Case reopened — restart escalation
            if (!RESOLVED_STATUSES.contains(c.Status) && RESOLVED_STATUSES.contains(old.Status)) {
                toUpdate.add(new Case(
                    Id = c.Id,
                    Next_Escalation_Email__c = DateTime.now().addMinutes(5)
                ));
            }
        }
        if (!toUpdate.isEmpty()) {
            update toUpdate;
        }
    }
}

Component 2: Schedulable Apex (runs every 15 minutes)

// ABOUTME: Scheduled job that queries cases due for escalation email and sends them.
// ABOUTME: Runs every 15 minutes via 4 cron entries. Implements Batchable for governor limits.

public class CaseEscalationEmailScheduler implements Schedulable {

    // Interval map: Priority → minutes until next email
    private static final Map<String, Integer> INTERVAL_MINUTES = new Map<String, Integer>{
        'High'   => 15,    // Sev-1
        'Medium' => 30,    // Sev-2
        'Low'    => 720    // Sev-3 (12 hours)
    };

    public void execute(SchedulableContext ctx) {
        Database.executeBatch(new CaseEscalationEmailBatch(), 50);
    }

    // Schedule all 4 quarter-hour jobs
    public static void scheduleAll() {
        System.schedule('Case Escalation :00', '0 0 * * * ?',  new CaseEscalationEmailScheduler());
        System.schedule('Case Escalation :15', '0 15 * * * ?', new CaseEscalationEmailScheduler());
        System.schedule('Case Escalation :30', '0 30 * * * ?', new CaseEscalationEmailScheduler());
        System.schedule('Case Escalation :45', '0 45 * * * ?', new CaseEscalationEmailScheduler());
    }

    public static Integer getIntervalMinutes(String priority) {
        return INTERVAL_MINUTES.containsKey(priority) ? INTERVAL_MINUTES.get(priority) : 720;
    }
}

Component 3: Batchable (handles volume + governor limits)

// ABOUTME: Batch class that sends escalation emails for cases past their next-email time.
// ABOUTME: Updates Next_Escalation_Email__c to the next interval after sending.

public class CaseEscalationEmailBatch implements Database.Batchable<SObject> {

    private static final Set<String> RESOLVED_STATUSES = new Set<String>{
        'Closed', 'Resolved'
    };

    public Database.QueryLocator start(Database.BatchableContext ctx) {
        DateTime now = DateTime.now();
        return Database.getQueryLocator([
            SELECT Id, CaseNumber, Subject, Priority, Status, OwnerId,
                   Contact.Email, Contact.Name,
                   Next_Escalation_Email__c, Escalation_Email_Count__c
            FROM Case
            WHERE Next_Escalation_Email__c <= :now
              AND Next_Escalation_Email__c != null
              AND Status NOT IN :RESOLVED_STATUSES
        ]);
    }

    public void execute(Database.BatchableContext ctx, List<Case> scope) {
        List<Messaging.SingleEmailMessage> emails = new List<Messaging.SingleEmailMessage>();
        List<Case> toUpdate = new List<Case>();

        // Optional: use an email template instead of inline body
        // EmailTemplate tmpl = [SELECT Id FROM EmailTemplate WHERE DeveloperName = 'Case_Escalation'];

        for (Case c : scope) {
            // Build email
            Messaging.SingleEmailMessage msg = new Messaging.SingleEmailMessage();

            // Send to case owner (and/or contact, distribution list, etc.)
            List<String> recipients = getRecipients(c);
            if (recipients.isEmpty()) continue;

            msg.setToAddresses(recipients);
            msg.setSubject('Escalation: Case ' + c.CaseNumber + '' + c.Subject);
            msg.setPlainTextBody(buildEmailBody(c));
            msg.setSaveAsActivity(true);
            emails.add(msg);

            // Update case: set next email time, increment counter
            Integer intervalMinutes = CaseEscalationEmailScheduler.getIntervalMinutes(c.Priority);
            Decimal count = c.Escalation_Email_Count__c == null ? 0 : c.Escalation_Email_Count__c;
            toUpdate.add(new Case(
                Id = c.Id,
                Next_Escalation_Email__c = DateTime.now().addMinutes(intervalMinutes),
                Last_Escalation_Email__c = DateTime.now(),
                Escalation_Email_Count__c = count + 1
            ));
        }

        if (!emails.isEmpty()) {
            Messaging.sendEmail(emails);
        }
        if (!toUpdate.isEmpty()) {
            update toUpdate;
        }
    }

    public void finish(Database.BatchableContext ctx) {
        // Optional: log completion, send admin summary, etc.
    }

    private List<String> getRecipients(Case c) {
        List<String> recipients = new List<String>();

        // Add contact email if exists
        if (c.Contact?.Email != null) {
            recipients.add(c.Contact.Email);
        }

        // TODO: Add case team members, escalation distribution lists,
        //       owner email, etc. based on your org's requirements
        return recipients;
    }

    private String buildEmailBody(Case c) {
        return 'This is an automated escalation reminder.\n\n'
             + 'Case Number: ' + c.CaseNumber + '\n'
             + 'Subject: ' + c.Subject + '\n'
             + 'Priority: ' + c.Priority + '\n'
             + 'Status: ' + c.Status + '\n\n'
             + 'This case requires attention. Escalation emails will continue '
             + 'until the case is resolved.\n\n'
             + 'Escalation email #' + (c.Escalation_Email_Count__c == null ? 1 : c.Escalation_Email_Count__c + 1);
    }
}

Deployment Steps

  1. Create custom fields on Case: Next_Escalation_Email__c, Last_Escalation_Email__c, Escalation_Email_Count__c
  2. Deploy trigger + handler — wire CaseEscalationTriggerHandler to after insert and after update
  3. Deploy scheduled + batch classes
  4. Schedule the jobs — run CaseEscalationEmailScheduler.scheduleAll() in Anonymous Apex
  5. Set up email template (optional, cleaner than inline body)

Governor Limit Considerations

Concern Mitigation
5000 email limit per day Batch size of 50. Monitor Escalation_Email_Count__c and cap if needed.
Scheduled job limit (100) Only 4 jobs. Leaves 96 slots free.
SOQL in batch start Single indexed query on Next_Escalation_Email__c. Fast.
Concurrent batch limit (5) Single batch per run. Unlikely to overlap at 15-min intervals.

Alternatives Considered

Approach Why Not
Apex cron at 15-min intervals Cron expressions only go to hourly. Need 4 jobs. (This is what we do.)
Platform Events + Trigger Overkill. No real-time need — polling every 15 min is fine.
Flow + Scheduled Path Flow scheduled paths have 1-hour minimum and limited bulkification.
Entitlement Processes + Milestones Good for SLA tracking but not for repeated email notifications at custom intervals. Could complement this.
Daisy-chain (job reschedules itself) Fragile. One failure breaks the chain.

Testing Notes

  • Test trigger: insert case → assert Next_Escalation_Email__c is ~5 min from now
  • Test trigger: update case to Closed → assert Next_Escalation_Email__c is null
  • Test batch: insert case with Next_Escalation_Email__c in the past → run batch → assert email sent + next interval set
  • Test interval map: assert Sev-1=15, Sev-2=30, Sev-3=720
  • Test edge: case with no contact email → no email sent, no error
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment