UPDATED June 3, 2018 – you can now add email addresses to cc or bcc in the configuration step. See updated code.
The Opportunity Contact Role is an odd beast, perhaps because it exists largely for information purposes in the B2B sales world for which Salesforce is built, but takes on weightier roles in nonprofit usage. In the nonprofit world, opportunities represent donations, grants applied for and won, and sometimes memberships and event registrations. From there arise a lot of solid use cases for sending automated emails to Contacts linked by Opportunity Contact Roles, but Email Alerts don’t allow you to access these contacts for automated mailing.
Facing this constraint, I built this Apex class that allows you to send an Opportunity-based email to contact roles using Process Builder to Call Apex and to define the template, roles of the desired recipients, and an attachment as needed. The class and related test class are below, or you can download them in a single text file; the image shows how to configure a Process Builder Action to take the place of an email alert to send your email based on the criteria you defined elsewhere in the flow.
Configuration
There are three required fields when you select this class: OpportunityID, which should be set as a field reference to the related opportunity; TemplateID, the id of the email template you’d like to use (be it html, Visualforce, or plain text), and one or more toRoleNames.
Like ccRoleNames and bccRoleNames, toRoleNames takes a comma-separated list of role names. If you want to send to the primary opportunity contact, use the name Primary, which shouldn’t also be the name of a Contact Role. To function correctly the role names need to be in proper case, and there should be no extra spaces before or after the role names. If multiple “to” contacts are specified, you have no control over which of them will end up being the “to” of record, for mail merge and links to the generated activity. If your list of roles doesn’t yield a “to” contact for a given opportunity, the primary contact will be used instead.
You can optionally define the displayName and replyTo for the email, list a single ID for a resource to attach, and set the boolean field saveAsActivity. By default, the class *will* save the email as an activity. If you prefer that it not do so, you must define this parameter as False.
A couple of potential improvements for you intrepid developers out there: adding trim() and toLowerCase() so that spacing and capitalization don’t cause issues; the ability to send separate emails to all of the “to” addresses; and the ability to add multiple attachments. Please share the code back here if you make those improvements.
Main Emailer Class
global class EmailOpportunityContactRole { @InvocableMethod(label='Send Email to Opportunity Contact Roles' description='Sends email related to an opportunity, based on opportunity contact roles') global static void SendToOppConRoles(list<OCREmailParameters> EParam){ for(OCREmailParameters EP: Eparam){ Messaging.SingleEmailMessage email = new Messaging.SingleEmailMessage(); string emailsubject = ''; string bodytext = ''; String[] toRoles = ep.toRoleNames.split(','); list<string> bccRoles = new list <string>(); if(string.isNotBlank(ep.bccRoleNames) ){ ep.bccRoleNames.split(',');} list<string> ccRoles = new list <string>(); if(string.isNotBlank(ep.ccRoleNames) ){ ccRoles = ep.ccRoleNames.split(',');} system.debug(ccroles); list<string> toEmails = new list<string>(); list<string> ccEmails = new list<string>(); list<string> bccEmails = new list<string>(); id toContact = null; id primaryContact = null; list<opportunitycontactrole> OCRs = [select contactid, isprimary, contact.firstname, contact.email, role from opportunitycontactrole where isdeleted = false and opportunityid = :ep.OpportunityId and contact.email != null and (role in :toroles or role in :bccroles or role in :ccroles or isprimary = true)]; system.debug(OCRs); for(opportunitycontactrole OCR: OCRs){ if(toRoles.contains(OCR.role) || (toRoles.contains('Primary')&&OCR.IsPrimary)){ if(string.isBlank(toContact)){ toContact = OCR.ContactId; } toEmails.add(OCR.Contact.Email); } else if (ccRoles.contains(OCR.role)|| (ccRoles.contains('Primary')&&OCR.IsPrimary)){ ccEmails.add(OCR.Contact.Email); } else if (bccRoles.contains(OCR.role)|| (bccRoles.contains('Primary')&&OCR.IsPrimary)){ bccEmails.add(OCR.Contact.Email); } if(OCR.IsPrimary){ primaryContact = OCR.ContactId; } } if(string.isNotBlank(ep.ccEmails) ){ list<string> addEmails = ep.ccEmails.split(','); for(string em: addEmails){ string eml = em.trim(); if(validateEmail(eml)){ ccEmails.add(eml); } } } if(string.isNotBlank(ep.bccEmails) ){ list<string> addEmails = ep.bccEmails.split(','); for(string em: addEmails){ string eml = em.trim(); if(validateEmail(eml)){ bccEmails.add(eml); } } } //work on contingencies if there is no "to" contact if(string.isblank(toContact)){ if(!string.isblank(primaryContact)){ toContact = primaryContact; } else if (!OCRs.isempty()) { toContact = OCRs[0].ContactID; toEmails.add(OCRs[0].Contact.email); } else { // if no recipients found, stop system.debug('No recipients found'); return; } } email.setToAddresses( toEmails ); if(!ccEmails.isempty()){ email.setCCAddresses( ccEmails); } if(!bccEmails.isempty()){ email.setBCCAddresses( bccEmails); } if(EP.saveAsActivity == null || EP.saveAsActivity){ email.setSaveAsActivity(true); } if(EP.replyto != null){ email.setReplyTo(EP.replyto); } if(EP.displayname != null){ email.setSenderDisplayName(EP.displayName); } //template related settings email.setWhatId(ep.opportunityid); email.settargetObjectId(toContact); email.setTemplateId(ep.TemplateId); //email.setPlainTextBody( bodytext ); system.debug(email); if(ep.ResourceToAttachId!=null){ //Attachments List<StaticResource> objPDF = [Select body, name from StaticResource where id = :ep.ResourceToAttachId]; if(!objPDF.isempty()){ Messaging.EmailFileAttachment[] objEmailAttachments = new Messaging.EmailFileAttachment[1]; Messaging.EmailFileAttachment objPDFAttachment = new Messaging.EmailFileAttachment(); objPDFAttachment.setBody(objPDF[0].Body); objPDFAttachment.setFileName(objPDF[0].name + '.pdf'); objEmailAttachments[0] = objPDFAttachment; email.setFileAttachments(objEmailAttachments); } } // Sends the email Messaging.SendEmailResult [] r = Messaging.sendEmail(new Messaging.SingleEmailMessage[] {email}); } } public static Boolean validateEmail(String email) { Boolean res = true; String emailRegex = '^[a-zA-Z0-9._|\\\\%#~`=?&/$^*!}{+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,4}$'; // source: http://www.regular-expressions.info/email.html Pattern MyPattern = Pattern.compile(emailRegex); Matcher MyMatcher = MyPattern.matcher(email); if (!MyMatcher.matches()) res = false; return res; } global class OCREmailParameters { @InvocableVariable(required=true) global ID OpportunityId; @InvocableVariable(required=true) global ID TemplateId; @InvocableVariable(required=true) global string toRoleNames; @InvocableVariable global string ccRoleNames; @InvocableVariable global string bccRoleNames; @InvocableVariable global string displayName; @InvocableVariable global string replyTo; @InvocableVariable global string ccEmails; @InvocableVariable global string bccEmails; @InvocableVariable global boolean saveAsActivity; @InvocableVariable global id ResourceToAttachId; } }
Test Class
For the test to perform optimally, you should have at least three potential contact roles in the contact role pick list, and at least one pdf somewhere in your static resources.
@isTest (SeeAllData=true) private class test_EmailOpportunityCR { static testMethod void Test_ECR(){ // get opportunity contact role values List<String> CRList= new List<String>(); Schema.DescribeFieldResult fieldResult = OpportunityContactRole.Role.getDescribe(); List<Schema.PicklistEntry> ple = fieldResult.getPicklistValues(); for( Schema.PicklistEntry pickListVal : ple){ CRList.add(pickListVal.getLabel()); } integer CRCount = CRList.size()>3?3:CRList.size(); system.debug(crlist); //create account account a = new account(name='Test',BillingCountry='USA'); insert a; //create contacts list<contact> cons = new list<contact>(); for (Integer i = 0; i < CRCount; i++){ contact c = new contact(firstname='Test', lastname='Test'+i,email='test'+i+'@test.com', accountid=a.id); cons.add(c); } insert cons; //opportunity opportunity o = new opportunity(name='test',accountid=a.id,closedate=system.today(),stagename='Payment Requested'); insert o; //opportunity contact roles list<opportunitycontactrole> ocrs = new list<opportunitycontactrole>(); for (Integer i = 0; i < CRCount; i++){ opportunitycontactrole ocr = new opportunitycontactrole(contactid=cons[i].id,role=CRList[i],opportunityid=o.id); ocrs.add(ocr); } ocrs[0].isprimary = true; insert ocrs; list<EmailOpportunityContactRole.OCREmailParameters> OCREPs= new list<EmailOpportunityContactRole.OCREmailParameters>(); EmailOpportunityContactRole.OCREmailParameters OCREP = new EmailOpportunityContactRole.OCREmailParameters(); OCREP.OpportunityId = o.id; OCREP.TemplateId = [select id from emailtemplate where isActive=true limit 1][0].id; OCREP.toRoleNames = CRList[0]; if(CRCount > 1){ OCREP.ccRoleNames = CRList[1]; } else { OCREP.ccRoleNames = 'Primary'; } if(CRCount > 2){ OCREP.bccRoleNames= CRList[2]; } OCREP.displayName ='Hank'; OCREP.ccemails ='Hank@test.com'; OCREP.bccemails ='Hank2@test.com'; OCREP.replyTo = 'test@test.com'; list<staticresource> SRList = [Select id from StaticResource limit 1]; if(!SRList.isEmpty()){ OCREP.ResourceToAttachId = SRList[0].id; } OCREPS.add(OCREP); EmailOpportunityContactRole.SendToOppConRoles(OCREPS); } }
Code updated March 22 after a user noticed that Contacts without email addresses caused problems.