Skip to content

Class Name: EmailService

Last Updated: 2025-10-22 Source Code: https://github.com/AANP-IT/I2C.Salesforce.Metadata/blob/STAGING/force-app/main/default/classes/EmailService.cls

API Name: EmailService Type: Service (Invocable) Test Coverage: Target 85%+

Business Purpose

The EmailService class provides a Flow-invocable method for sending templated order confirmation emails to customers. This service decouples email logic from Flow builders, allowing: - Bulk email sending from Process Builder or Flow - Template-based email composition - Org-wide email address support for branding - Graceful handling of missing contact information - Partial success for multi-order scenarios

Typically invoked from Order fulfillment flows after an order reaches "Fulfilled" status.

Class Overview

  • Scope/Sharing: with sharing - Respects record-level security
  • Key Responsibilities:
  • Bulk email sending for order confirmations
  • Template-based email composition
  • Contact lookup and validation
  • Partial-success email delivery
  • Error logging for failed sends

Design Philosophy

This class follows the Invocable Method Pattern for Flow integration, accepting a list of input requests and processing them in bulk. It uses Salesforce's Messaging API for email delivery with template merge fields automatically populated from Order and Contact data.

Inner Classes

EmailRequest

public class EmailRequest {
    @InvocableVariable(required=true)
    public Id orderId;

    @InvocableVariable(required=true)
    public Id emailTemplateId;

    @InvocableVariable(required=true)
    public Id orgWideEmailAddressId;
}

Purpose: Wrapper class defining input parameters for the invocable method.

Fields: - orderId (Id, required): Salesforce Order record ID for email context - emailTemplateId (Id, required): Email Template to use for message composition - orgWideEmailAddressId (Id, required): Organization-Wide Email Address for "From" branding

Usage Notes: - All fields required - Flow must provide values - No validation on field types (assumes Flow passes correct record IDs) - No description attributes (Flow displays API names)

Recommended Improvements:

public class EmailRequest {
    @InvocableVariable(
        label='Order ID'
        description='The Order record to send confirmation for'
        required=true
    )
    public Id orderId;

    @InvocableVariable(
        label='Email Template ID'
        description='Email Template to use (must be Classic Email Template)'
        required=true
    )
    public Id emailTemplateId;

    @InvocableVariable(
        label='Org-Wide Email Address ID'
        description='Organization-Wide Email Address for sender branding'
        required=true
    )
    public Id orgWideEmailAddressId;
}

Public Methods

sendOrderConfirmationEmails

@InvocableMethod(label='Send Order Confirmation Email')
public static void sendOrderConfirmationEmails(List<EmailRequest> requests)

Purpose: Sends templated order confirmation emails to order bill-to contacts in bulk.

Parameters: - requests (List) - List of email send requests from Flow

Returns: void - No return value (errors logged to System.debug)

Invocable Attributes: - Label: "Send Order Confirmation Email" (displayed in Flow builder) - Bulk Processing: Processes up to 200 requests per Flow transaction

Business Logic:

  1. Empty Request Guard (lines 16-18):
    if (requests.isEmpty()) {
        return;
    }
    
  2. Returns immediately if no requests provided
  3. Prevents unnecessary processing

  4. Order ID Collection (lines 20-23):

    Set<Id> orderIds = new Set<Id>();
    for (EmailRequest request : requests) {
        orderIds.add(request.orderId);
    }
    

  5. Builds set of unique Order IDs for bulk query
  6. Deduplicates if same order appears multiple times

  7. Order Query (line 25):

    Map<Id, Order> ordersById = new Map<Id, Order>(
        OrderService.getOrders(orderIds)
    );
    

  8. Delegates to OrderService.getOrders() for query logic
  9. Returns map of Order ID → Order record
  10. Assumes OrderService retrieves necessary fields including BillToContactId

  11. Email Message Construction (lines 29-43):

    for (EmailRequest request : requests) {
        Order order = ordersById.get(request.orderId);
    
        if (order != null && order.BillToContactId != null) {
            Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage();
            email.setTargetObjectId(order.BillToContactId);     // Recipient
            email.setWhatId(order.Id);                          // Related record
            email.setTemplateId(request.emailTemplateId);       // Template
            email.setOrgWideEmailAddressId(request.orgWideEmailAddressId); // From
            email.setSaveAsActivity(false);                     // No activity log
            emailsToSend.add(email);
        } else {
            System.debug('Skipping order with missing or null BillToContactId: ' + request.orderId);
        }
    }
    

Field Mappings: - TargetObjectId: order.BillToContactId (email recipient) - WhatId: order.Id (related Order for template merge fields) - TemplateId: request.emailTemplateId (Classic Email Template) - OrgWideEmailAddressId: Sender branding - SaveAsActivity: false (no task/email message created)

Skipped Scenarios: - Order not found in query results - Order has null BillToContactId - Logs to System.debug (line 41)

  1. Bulk Email Send (lines 45-57):
    if (!emailsToSend.isEmpty()) {
        try {
            Messaging.SendEmailResult[] results = Messaging.sendEmail(emailsToSend, false);
            for (Integer i = 0; i < results.size(); i++) {
                if (!results[i].isSuccess()) {
                    System.debug('Failed to send email for request: ' + requests[i]
                        + ' | Errors: ' + results[i].getErrors());
                }
            }
        } catch (Exception e) {
            System.debug('Email sending failed with exception: ' + e.getMessage());
        }
    }
    

Email Send Options: - allOrNone: false (partial success enabled) - Allows individual email failures without stopping others - Returns SendEmailResult[] for per-email status

Error Handling: - Logs failed sends with error details (line 51) - Catches and logs exceptions during send operation (line 55) - Does NOT re-throw exceptions (silent failure to Flow)

Issues/Concerns: - ⚠️ OrderService Dependency: Relies on external service for query (line 25) - If OrderService throws exception, entire method fails - No fallback or error handling for query failures - ⚠️ System.debug Only: All errors logged to debug logs (lines 41, 51, 55) - Lost after 24 hours - No persistent error tracking - Flow has no visibility into failures - ⚠️ No Return Value: Flow cannot detect success/failure (void return) - ⚠️ SaveAsActivity=false: No audit trail of sent emails - Cannot track which emails were sent to which contacts - No resend capability - ⚠️ Classic Email Templates Only: Code uses setTemplateId() which requires Classic templates - Lightning Email Templates not supported - ⚠️ No Email Limits Check: Doesn't verify daily email limit before sending - Could hit org limit (5,000 emails/day for most orgs) - ✅ Bulk Processing: Efficient single-send for multiple emails - ✅ Partial Success: Individual failures don't stop entire batch - ✅ Null-Safe: Checks for null order and contact before sending

Template Merge Fields: When setTargetObjectId() and setWhatId() are set, template can reference: - {!Contact.FirstName}: Recipient's first name - {!Contact.Email}: Recipient's email - {!Order.OrderNumber}: Order number - {!Order.TotalAmount}: Order total - {!RelatedTo.CustomField__c}: Any Order custom field

Example Flow Usage:

Flow: Order Fulfillment Email Notification

Trigger: Order Status = "Fulfilled"

Actions:
1. Get Order record (already in trigger)
2. [Action] Send Order Confirmation Email
   - Order ID: {!Order.Id}
   - Email Template ID: [Select from picklist]
   - Org-Wide Email Address ID: [Select from picklist]

Example Anonymous Apex Usage:

// Construct request
EmailService.EmailRequest request = new EmailService.EmailRequest();
request.orderId = '8011234567890ABC';
request.emailTemplateId = '00X1234567890ABC'; // Classic Email Template ID
request.orgWideEmailAddressId = '0D21234567890ABC';

// Send email
EmailService.sendOrderConfirmationEmails(new List<EmailService.EmailRequest>{request});

Governor Limits Impact: - Email Sends: 1 send per request (single sendEmail() call for all) - Daily Email Limit: Consumes from org's daily limit (5,000 default) - SOQL Queries: 1 query (via OrderService) - SOQL Rows: Number of unique orders - Heap: Proportional to number of emails

Dependencies

Salesforce Objects

  • Order (Standard Object)
  • Fields: Id, BillToContactId, plus any fields used in email template
  • Access: Read (via OrderService)
  • Contact (Standard Object)
  • Implicit usage as email recipient (BillToContactId)
  • Fields: Email address, any fields used in template merge fields
  • EmailTemplate (Standard Object)
  • Classic Email Template required
  • Must be active and accessible

Custom Settings/Metadata

  • Organization-Wide Email Address: Must be verified and enabled
  • Configured in Setup → Organization-Wide Addresses
  • ID passed in request parameter

Other Classes

  • OrderService: Provides getOrders() method for querying Order records
  • Must return Orders with Id and BillToContactId fields
  • Critical dependency - failure blocks all email sends

External Services

  • Salesforce Email Relay: Sends emails via Salesforce infrastructure
  • Email Deliverability Settings: Must be configured for production sends

Salesforce APIs

  • Messaging.SingleEmailMessage: Standard email messaging API
  • Messaging.sendEmail(): Bulk email send method

Design Patterns

  1. Invocable Method Pattern: Flow integration via @InvocableMethod
  2. Wrapper Class Pattern: EmailRequest encapsulates input parameters
  3. Bulk Processing: Processes multiple requests in single transaction
  4. Partial Success: allOrNone=false allows individual failures
  5. Service Delegation: Delegates Order query to OrderService
  6. Template-Based Email: Uses Salesforce email templates for content

Governor Limits Considerations

Current Impact (Per Transaction)

  • SOQL Queries: 1 (via OrderService)
  • SOQL Rows: Variable (number of unique orders)
  • Email Sends: 1 (single sendEmail() call)
  • Emails Sent: Variable (limited by Flow transaction)
  • Heap Size: Proportional to number of email messages
  • CPU Time: Minimal (mostly Salesforce API processing)

Email Limits

  • Single Execution: 10 emails per sendEmail() call in test context
  • Transaction: Unlimited SingleEmailMessage objects, but practical limit ~100-200
  • Daily Org Limit:
  • Developer Edition: 5 emails/day
  • Production: 5,000 emails/day (or 1,000 × license count, whichever is greater)
  • Per-User Limit: 5,000 emails/day per user

Scalability Analysis

  • Bulk Email Send: Single API call for all emails
  • ⚠️ Flow Invocable Limit: Maximum 200 inputs per invocable call
  • Cannot send >200 emails per Flow transaction
  • ⚠️ Daily Limit Risk: High-volume order systems could hit daily limit
  • ⚠️ No Queueable Alternative: Cannot defer to async processing
  • ⚠️ OrderService Query: Scalability depends on OrderService implementation

Recommendations

  1. Daily Limit Monitoring:

    // Add limit check before sending
    Integer remainingEmails = Limits.getLimitEmailInvocations() - Limits.getEmailInvocations();
    if (emailsToSend.size() > remainingEmails) {
        System.debug('Email limit exceeded. Emails requested: ' + emailsToSend.size());
        // Consider queueable or scheduled batch for remaining emails
    }
    

  2. High-Volume Alternative:

    // For >200 orders, use batch Apex instead
    Database.executeBatch(new OrderEmailBatch(orderIds, templateId, orgWideId), 200);
    

Error Handling

Exception Types Thrown

  • None - All exceptions caught and logged

Exception Types Caught

  • Exception (line 54): Generic catch during sendEmail() call
  • Email delivery failures
  • Template rendering errors
  • Invalid recipient addresses

Error Handling Strategy

  1. Empty List Guard: Returns early for empty requests
  2. Null Checks: Skips orders without BillToContactId
  3. Try-Catch Wrapper: Prevents exception propagation to Flow
  4. Partial Success: sendEmail(messages, false) continues after individual failures
  5. Debug Logging: All errors logged to System.debug

Error Handling Gaps

  1. No Persistent Error Logging: Errors lost after 24 hours
  2. No Flow Feedback: Flow cannot detect failures (void return)
  3. Silent OrderService Failures: If OrderService.getOrders() throws exception, entire method fails
  4. No Retry Mechanism: Failed emails not retried
  5. No Email Limit Checks: Could exceed daily limit without warning

Monitoring Recommendations

// Query email status (if SaveAsActivity=true)
SELECT Id, Status, Subject, ToAddress, MessageDate
FROM EmailMessage
WHERE RelatedToId IN :orderIds
  AND CreatedDate = TODAY

// Check email deliverability
SELECT Id, Message, MessageDate, EmailTemplateName
FROM EmailStatus
WHERE CreatedDate = TODAY

// Monitor daily email usage
System.debug('Emails sent today: ' + Limits.getEmailInvocations());
System.debug('Daily limit: ' + Limits.getLimitEmailInvocations());
@InvocableMethod(label='Send Order Confirmation Email')
public static List<EmailResult> sendOrderConfirmationEmails(List<EmailRequest> requests) {
    List<EmailResult> results = new List<EmailResult>();

    if (requests.isEmpty()) {
        return results;
    }

    // Check email limits
    if (requests.size() > Limits.getLimitEmailInvocations()) {
        for (EmailRequest req : requests) {
            results.add(new EmailResult(req.orderId, false, 'Daily email limit exceeded'));
        }
        return results;
    }

    try {
        // ... existing logic ...

        Messaging.SendEmailResult[] sendResults = Messaging.sendEmail(emailsToSend, false);
        for (Integer i = 0; i < sendResults.size(); i++) {
            if (sendResults[i].isSuccess()) {
                results.add(new EmailResult(requests[i].orderId, true, 'Email sent successfully'));
            } else {
                String errors = String.join(sendResults[i].getErrors(), '; ');
                results.add(new EmailResult(requests[i].orderId, false, errors));

                // Log persistent error
                insert new Flow_Error_Log__c(
                    FlowName__c = 'EmailService',
                    ErrorMessage__c = errors,
                    ObjectName__c = 'Order',
                    FlowRunDateTime__c = System.now()
                );
            }
        }
    } catch (Exception e) {
        for (EmailRequest req : requests) {
            results.add(new EmailResult(req.orderId, false, e.getMessage()));
        }
    }

    return results;
}

public class EmailResult {
    @InvocableVariable
    public Id orderId;

    @InvocableVariable
    public Boolean success;

    @InvocableVariable
    public String errorMessage;

    public EmailResult(Id orderId, Boolean success, String errorMessage) {
        this.orderId = orderId;
        this.success = success;
        this.errorMessage = errorMessage;
    }
}

Security Considerations

Sharing Model

  • WITH SHARING: Respects record-level security
  • Implication: User must have access to Order and Contact records
  • Email Templates: Must be accessible to running user

Email Security

  • Recipient Validation: No validation of email address format
  • Email Spoofing: Org-Wide Email Address must be verified to prevent spoofing
  • Template Access: Classic Email Templates can contain merge fields exposing sensitive data
  • BCC/CC: Not supported in this implementation

Data Access

  • Order Fields: Any fields in template merge fields must be accessible
  • Contact Fields: Contact merge fields must be readable
  • Audit Trail: SaveAsActivity=false means no record of sent emails

Best Practices

  1. Verify Org-Wide Addresses: Ensure sender email is verified
  2. Template Review: Audit email templates for sensitive data exposure
  3. Access Control: Restrict who can execute this Flow action
  4. Deliverability: Configure SPF/DKIM for better email delivery

Test Class Requirements

Required Test Coverage

@IsTest
public class EmailServiceTest {

    @TestSetup
    static void setup() {
        // Create test account and contact
        Account acc = new Account(Name = 'Test Account');
        insert acc;

        Contact con = new Contact(
            FirstName = 'Test',
            LastName = 'Buyer',
            Email = 'test@example.com',
            AccountId = acc.Id
        );
        insert con;

        // Create test order
        Order ord = new Order(
            AccountId = acc.Id,
            EffectiveDate = Date.today(),
            Status = 'Draft',
            BillToContactId = con.Id
        );
        insert ord;
    }

    @IsTest
    static void testSendOrderConfirmationEmail_Success() {
        Order ord = [SELECT Id, BillToContactId FROM Order LIMIT 1];
        EmailTemplate template = [SELECT Id FROM EmailTemplate WHERE DeveloperName = 'Order_Confirmation' LIMIT 1];
        OrgWideEmailAddress orgWide = [SELECT Id FROM OrgWideEmailAddress LIMIT 1];

        EmailService.EmailRequest request = new EmailService.EmailRequest();
        request.orderId = ord.Id;
        request.emailTemplateId = template.Id;
        request.orgWideEmailAddressId = orgWide.Id;

        Test.startTest();
        EmailService.sendOrderConfirmationEmails(new List<EmailService.EmailRequest>{request});
        Test.stopTest();

        // Verify email was sent (check email invocations)
        Assert.isTrue(Limits.getEmailInvocations() > 0, 'Should send at least one email');
    }

    @IsTest
    static void testSendOrderConfirmationEmail_MissingContact() {
        Order ord = [SELECT Id FROM Order LIMIT 1];
        ord.BillToContactId = null;
        update ord;

        EmailTemplate template = [SELECT Id FROM EmailTemplate WHERE DeveloperName = 'Order_Confirmation' LIMIT 1];
        OrgWideEmailAddress orgWide = [SELECT Id FROM OrgWideEmailAddress LIMIT 1];

        EmailService.EmailRequest request = new EmailService.EmailRequest();
        request.orderId = ord.Id;
        request.emailTemplateId = template.Id;
        request.orgWideEmailAddressId = orgWide.Id;

        Test.startTest();
        EmailService.sendOrderConfirmationEmails(new List<EmailService.EmailRequest>{request});
        Test.stopTest();

        // Should skip sending (no exception)
        Assert.areEqual(0, Limits.getEmailInvocations(), 'Should not send email without contact');
    }

    @IsTest
    static void testSendOrderConfirmationEmail_BulkProcessing() {
        // Create multiple orders
        List<Order> orders = new List<Order>();
        Contact con = [SELECT Id FROM Contact LIMIT 1];
        Account acc = [SELECT Id FROM Account LIMIT 1];

        for (Integer i = 0; i < 5; i++) {
            orders.add(new Order(
                AccountId = acc.Id,
                EffectiveDate = Date.today(),
                Status = 'Draft',
                BillToContactId = con.Id
            ));
        }
        insert orders;

        EmailTemplate template = [SELECT Id FROM EmailTemplate WHERE DeveloperName = 'Order_Confirmation' LIMIT 1];
        OrgWideEmailAddress orgWide = [SELECT Id FROM OrgWideEmailAddress LIMIT 1];

        List<EmailService.EmailRequest> requests = new List<EmailService.EmailRequest>();
        for (Order ord : orders) {
            EmailService.EmailRequest request = new EmailService.EmailRequest();
            request.orderId = ord.Id;
            request.emailTemplateId = template.Id;
            request.orgWideEmailAddressId = orgWide.Id;
            requests.add(request);
        }

        Test.startTest();
        EmailService.sendOrderConfirmationEmails(requests);
        Test.stopTest();

        // Should send all emails in single call
        Assert.isTrue(Limits.getEmailInvocations() > 0, 'Should send bulk emails');
    }

    @IsTest
    static void testSendOrderConfirmationEmail_EmptyList() {
        Test.startTest();
        EmailService.sendOrderConfirmationEmails(new List<EmailService.EmailRequest>());
        Test.stopTest();

        // Should handle gracefully
        Assert.areEqual(0, Limits.getEmailInvocations(), 'Should not send emails for empty list');
    }

    @IsTest
    static void testSendOrderConfirmationEmail_OrderNotFound() {
        EmailTemplate template = [SELECT Id FROM EmailTemplate WHERE DeveloperName = 'Order_Confirmation' LIMIT 1];
        OrgWideEmailAddress orgWide = [SELECT Id FROM OrgWideEmailAddress LIMIT 1];

        EmailService.EmailRequest request = new EmailService.EmailRequest();
        request.orderId = '8010000000000000'; // Non-existent order
        request.emailTemplateId = template.Id;
        request.orgWideEmailAddressId = orgWide.Id;

        Test.startTest();
        EmailService.sendOrderConfirmationEmails(new List<EmailService.EmailRequest>{request});
        Test.stopTest();

        // Should skip gracefully (no exception)
        Assert.areEqual(0, Limits.getEmailInvocations(), 'Should not send email for missing order');
    }
}

Test Data Requirements

  • Account: Standard account for order association
  • Contact: With valid email address for recipient
  • Order: With BillToContactId populated
  • EmailTemplate: Classic Email Template (create in setup or reference existing)
  • OrgWideEmailAddress: Verified org-wide email address

Mocking Considerations

  • OrderService: May need mock if OrderService has complex logic
  • Email Delivery: Use Test.startTest()/stopTest() to reset email limits
  • Template Requirements: Ensure test template exists in all environments

Changes & History

Date Author Description
Unknown Original Developer Initial implementation
(Current) - Documentation added

Pre-Go-Live Concerns

CRITICAL

  • No Error Visibility to Flow: Flow cannot detect send failures (void return)
  • Add return type List<EmailResult> with success/failure indicators
  • Allows Flow to branch based on results

HIGH

  • OrderService Dependency: Single point of failure
  • If OrderService.getOrders() throws exception, all emails fail
  • Add try-catch around OrderService call
  • No Persistent Error Logging: Errors only in debug logs
  • Add Flow_Error_Log__c inserts for failures
  • Enable operations team to investigate issues
  • SaveAsActivity=false: No audit trail
  • Consider setting to true for compliance requirements
  • Allows tracking of customer communications

MEDIUM

  • No Email Limit Checks: Could exceed daily limit
  • Add check against Limits.getLimitEmailInvocations()
  • Log warning or throw exception if limit approached
  • Classic Templates Only: Lightning Email Templates not supported
  • Document limitation for template creators
  • Plan migration if Lightning templates needed

LOW

  • No Return Value: Flow cannot use output in decision logic
  • Return List for Flow branching
  • System.debug Only: Consider Platform Events for real-time monitoring

Maintenance Notes

📋 Monitoring Recommendations

  • Daily Email Usage: Track daily email sends vs org limit
  • Failed Sends: Monitor debug logs for "Failed to send email" messages
  • Template Changes: Test email templates after modifications
  • Org-Wide Address: Verify sender addresses remain active

🔧 Future Enhancement Opportunities

  1. Return Status to Flow: Add EmailResult output for Flow decisions
  2. Persistent Error Logging: Insert Flow_Error_Log__c records
  3. Lightning Template Support: Migrate to Lightning Email Builder
  4. Queueable Alternative: For high-volume scenarios
  5. Email Limit Tracking: Custom object to track daily usage

⚠️ Breaking Change Risks

  • Changing return type from void to List<EmailResult> requires Flow updates
  • Modifying EmailRequest class fields breaks existing Flow actions
  • Changing OrderService.getOrders() signature breaks integration
  • OrderService: Provides order query functionality
  • Order Object: Email context and template merge fields
  • Contact Object: Email recipients
  • EmailTemplate: Classic email templates
  • Organization-Wide Email Addresses: Sender branding
  • Flows: Order fulfillment and notification flows

Business Owner

Primary Contact: Customer Success / Operations Team Technical Owner: Salesforce Development Team Last Reviewed: [Date]


Documentation Status: ✅ Complete Code Review Status: ⚠️ Requires enhancement (add return values, error logging) Test Coverage: Target 85%+