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
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:
- Empty Request Guard (lines 16-18):
- Returns immediately if no requests provided
-
Prevents unnecessary processing
-
Order ID Collection (lines 20-23):
- Builds set of unique Order IDs for bulk query
-
Deduplicates if same order appears multiple times
-
Order Query (line 25):
- Delegates to
OrderService.getOrders()for query logic - Returns map of Order ID → Order record
-
Assumes
OrderServiceretrieves necessary fields includingBillToContactId -
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)
- 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
IdandBillToContactIdfields - 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¶
- Invocable Method Pattern: Flow integration via
@InvocableMethod - Wrapper Class Pattern:
EmailRequestencapsulates input parameters - Bulk Processing: Processes multiple requests in single transaction
- Partial Success:
allOrNone=falseallows individual failures - Service Delegation: Delegates Order query to
OrderService - 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
SingleEmailMessageobjects, 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
OrderServiceimplementation
Recommendations¶
-
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 } -
High-Volume Alternative:
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¶
- Empty List Guard: Returns early for empty requests
- Null Checks: Skips orders without
BillToContactId - Try-Catch Wrapper: Prevents exception propagation to Flow
- Partial Success:
sendEmail(messages, false)continues after individual failures - Debug Logging: All errors logged to System.debug
Error Handling Gaps¶
- No Persistent Error Logging: Errors lost after 24 hours
- No Flow Feedback: Flow cannot detect failures (void return)
- Silent OrderService Failures: If
OrderService.getOrders()throws exception, entire method fails - No Retry Mechanism: Failed emails not retried
- 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());
Recommended Improvements¶
@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=falsemeans no record of sent emails
Best Practices¶
- Verify Org-Wide Addresses: Ensure sender email is verified
- Template Review: Audit email templates for sensitive data exposure
- Access Control: Restrict who can execute this Flow action
- 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
BillToContactIdpopulated - 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__cinserts for failures - Enable operations team to investigate issues
- SaveAsActivity=false: No audit trail
- Consider setting to
truefor 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¶
- Return Status to Flow: Add EmailResult output for Flow decisions
- Persistent Error Logging: Insert Flow_Error_Log__c records
- Lightning Template Support: Migrate to Lightning Email Builder
- Queueable Alternative: For high-volume scenarios
- Email Limit Tracking: Custom object to track daily usage
⚠️ Breaking Change Risks¶
- Changing return type from
voidtoList<EmailResult>requires Flow updates - Modifying
EmailRequestclass fields breaks existing Flow actions - Changing
OrderService.getOrders()signature breaks integration
🔗 Related Components¶
- 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%+