├── src ├── classes │ ├── mc_TestData.cls-meta.xml │ ├── mc_SubscriptionSyncBatch.cls-meta.xml │ ├── mc_SubscriptionSyncBatchTest.cls-meta.xml │ ├── mc_SubscriptionSyncHandler.cls-meta.xml │ ├── mc_SubscriptionSyncHandlerTest.cls-meta.xml │ ├── mc_SubscriptionSyncHandlerTest.cls │ ├── mc_SubscriptionSyncHandler.cls │ ├── mc_SubscriptionSyncBatchTest.cls │ ├── mc_TestData.cls │ └── mc_SubscriptionSyncBatch.cls ├── triggers │ ├── mc_LeadTrigger.trigger-meta.xml │ ├── mc_ContactTrigger.trigger-meta.xml │ ├── mc_LeadTrigger.trigger │ └── mc_ContactTrigger.trigger ├── package.xml ├── profiles │ └── Admin.profile └── objects │ ├── Lead.object │ ├── Contact.object │ └── mc_Marketing_Cloud_Sync_Settings__c.object └── README.md /src/classes/mc_TestData.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncBatch.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/triggers/mc_LeadTrigger.trigger-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncBatchTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncHandler.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/triggers/mc_ContactTrigger.trigger-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncHandlerTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 41.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /src/triggers/mc_LeadTrigger.trigger: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Trigger for the Lead object to process opt-ins and opt-outs 5 | * 6 | * CHANGE LOG 7 | **/ 8 | trigger mc_LeadTrigger on Lead (before update) { 9 | 10 | // Only process if enabled in Custom Setting 11 | if (mc_Marketing_Cloud_Sync_Settings__c.getInstance().Lead_Sync__c) { 12 | 13 | // Send to the handling class for processing 14 | mc_SubscriptionSyncHandler.syncEmailSubscriptionMarketingCloud (trigger.new, trigger.oldMap, 'Lead'); 15 | } 16 | } -------------------------------------------------------------------------------- /src/triggers/mc_ContactTrigger.trigger: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Trigger for the Contact object to process opt-ins and opt-outs 5 | * 6 | * CHANGE LOG 7 | **/ 8 | trigger mc_ContactTrigger on Contact (before update) { 9 | 10 | // Only process if enabled in Custom Setting 11 | if (mc_Marketing_Cloud_Sync_Settings__c.getInstance().Contact_Sync__c) { 12 | 13 | // Send to the handling class for processing 14 | mc_SubscriptionSyncHandler.syncEmailSubscriptionMarketingCloud (trigger.new, trigger.oldMap, 'Contact'); 15 | } 16 | } -------------------------------------------------------------------------------- /src/package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | * 5 | ApexClass 6 | 7 | 8 | * 9 | ApexTrigger 10 | 11 | 12 | Contact.mc_Marketing_Cloud_Sync_Error__c 13 | Contact.mc_Sync_to_Marketing_Cloud__c 14 | Lead.mc_Marketing_Cloud_Sync_Error__c 15 | Lead.mc_Sync_to_Marketing_Cloud__c 16 | CustomField 17 | 18 | 19 | * 20 | CustomObject 21 | 22 | 23 | Admin 24 | Profile 25 | 26 | 36.0 27 | 28 | -------------------------------------------------------------------------------- /src/profiles/Admin.profile: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | 5 | true 6 | Contact.mc_Marketing_Cloud_Sync_Error__c 7 | true 8 | 9 | 10 | true 11 | Contact.mc_Sync_to_Marketing_Cloud__c 12 | true 13 | 14 | 15 | true 16 | Lead.mc_Marketing_Cloud_Sync_Error__c 17 | true 18 | 19 | 20 | true 21 | Lead.mc_Sync_to_Marketing_Cloud__c 22 | true 23 | 24 | Salesforce 25 | 26 | -------------------------------------------------------------------------------- /src/objects/Lead.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | mc_Marketing_Cloud_Sync_Error__c 5 | Displays an error message if a Lead Email Opt Out field change failed to sync to Marketing Cloud. 6 | false 7 | Displays an error message if a Lead Email Opt Out field change failed to sync to Marketing Cloud. 8 | 9 | 255 10 | false 11 | false 12 | Text 13 | false 14 | 15 | 16 | mc_Sync_to_Marketing_Cloud__c 17 | false 18 | Controls whether to sync the record to Marketing Cloud for the batch processing. 19 | false 20 | Controls whether to sync the record to Marketing Cloud for the batch processing. 21 | 22 | false 23 | Checkbox 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/objects/Contact.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | mc_Marketing_Cloud_Sync_Error__c 5 | Displays an error message if a Contact Email Opt Out field change failed to sync to Marketing Cloud. 6 | false 7 | Displays an error message if a Contact Email Opt Out field change failed to sync to Marketing Cloud. 8 | 9 | 255 10 | false 11 | false 12 | Text 13 | false 14 | 15 | 16 | mc_Sync_to_Marketing_Cloud__c 17 | false 18 | Controls whether to sync the record to Marketing Cloud for the batch processing. 19 | false 20 | Controls whether to sync the record to Marketing Cloud for the batch processing. 21 | 22 | false 23 | Checkbox 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncHandlerTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Test class for the triggers and mc_SubscriptionSyncHandler 5 | * 6 | * CHANGE LOG 7 | **/ 8 | @isTest 9 | public class mc_SubscriptionSyncHandlerTest { 10 | 11 | /** 12 | * @author Ben Edwards (ben@edwards.nz) 13 | * @description Test scenario of creating a Contact and checking HasOptedOutOfEmail 14 | **/ 15 | @isTest 16 | static void testContactTrigger () { 17 | 18 | // Create a new Contact 19 | Contact testContact = mc_TestData.createContact(); 20 | 21 | // Set opt-out 22 | testContact.HasOptedOutOfEmail = true; 23 | update testContact; 24 | } 25 | 26 | 27 | /** 28 | * @author Ben Edwards (ben@edwards.nz) 29 | * @description Test scenario of creating a Lead and checking HasOptedOutOfEmail 30 | **/ 31 | @isTest 32 | static void testLeadTrigger () { 33 | 34 | // Create a new Lead 35 | Lead testLead = mc_TestData.createLead(); 36 | 37 | // Set opt-out 38 | testLead.HasOptedOutOfEmail = true; 39 | update testLead; 40 | } 41 | 42 | 43 | /** 44 | * @author Ben Edwards (ben@edwards.nz) 45 | * @description Create all test data for the test methods 46 | **/ 47 | @testSetup 48 | static void testSetupData () { 49 | 50 | // Enable the Lead and Contact auto-sync 51 | mc_Marketing_Cloud_Sync_Settings__c syncSettings = new mc_Marketing_Cloud_Sync_Settings__c(SetupOwnerId = System.Userinfo.getOrganizationId()); 52 | syncSettings.Contact_Sync__c = true; 53 | syncSettings.Lead_Sync__c = true; 54 | insert syncSettings; 55 | } 56 | 57 | } -------------------------------------------------------------------------------- /src/objects/mc_Marketing_Cloud_Sync_Settings__c.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | Hierarchy 4 | Controls whether the auto-sync of Lead and Contact subscription changes should sync to Marketing Cloud. 5 | false 6 | 7 | Contact_Sync__c 8 | true 9 | Enable or disable auto-syncing of HasOptedOutOfEmail changes to Marketing Cloud for the Contact object. 10 | false 11 | Enable or disable auto-syncing of HasOptedOutOfEmail changes to Marketing Cloud for the Contact object. 12 | 13 | false 14 | Checkbox 15 | 16 | 17 | Lead_Sync__c 18 | true 19 | Enable or disable auto-syncing of HasOptedOutOfEmail changes to Marketing Cloud for the Lead object. 20 | false 21 | Enable or disable auto-syncing of HasOptedOutOfEmail changes to Marketing Cloud for the Lead object. 22 | 23 | false 24 | Checkbox 25 | 26 | 27 | Protected 28 | 29 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncHandler.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Handle the pushing of opt-in's and opt-outs to Marketing Cloud 5 | * 6 | * CHANGE LOG 7 | **/ 8 | public class mc_SubscriptionSyncHandler { 9 | 10 | /** 11 | * @author Ben Edwards (ben@edwards.nz) 12 | * @description Process the syncing of opt-in/opt-out changes to Marketing Cloud 13 | * @return void 14 | **/ 15 | public static void syncEmailSubscriptionMarketingCloud (List newList, Map oldMap, String objectName) { 16 | 17 | // Boolean to control whether the sync batch should be started 18 | Boolean startSyncBatch = false; 19 | 20 | // Iterate over the record set 21 | for (SObject record :newList) { 22 | 23 | // Get the record Id for the dynamic SObject 24 | Id recordId = (Id) record.get('Id'); 25 | 26 | // If the email opt out has changed 27 | if (record.get('HasOptedOutOfEmail') != oldMap.get(recordId).get('HasOptedOutOfEmail')) { 28 | 29 | // If there is a record to sync, set a flag on the record. This enables the batch class to pick it up 30 | record.put('mc_Sync_to_Marketing_Cloud__c', true); 31 | 32 | // Set the variable flag to ensure batch is executed immediately from here 33 | startSyncBatch = true; 34 | } 35 | } 36 | 37 | // If there are records to sync, start the batch 38 | if (startSyncBatch) { 39 | 40 | // Start the batch and run in series of 1. As this has to make a single callout per contact or lead (not bulkified) 41 | // Only want to run in small batches 42 | // Also note, this process will only work for users who have an associated Marketing CLoud user account 43 | // for all other users, a scheduled batch will run as system admin to sync the opt-outs 44 | Database.executeBatch(new mc_SubscriptionSyncBatch(objectName), 1); 45 | } 46 | 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncBatchTest.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Test class for the mc_SubscriptionSyncBatch class 5 | * 6 | * CHANGE LOG 7 | **/ 8 | @isTest 9 | public class mc_SubscriptionSyncBatchTest { 10 | 11 | 12 | /** 13 | * @author Ben Edwards (ben@edwards.nz) 14 | * @description Test scenario of processing batch for Contacts 15 | **/ 16 | @isTest 17 | static void testContactBatchProcessingSubscribe () { 18 | 19 | // Create a new Contact 20 | Contact testContact = mc_TestData.createContact(); 21 | 22 | // Set the Contact to sync 23 | testContact.mc_Sync_to_Marketing_Cloud__c = true; 24 | update testContact; 25 | 26 | // Process the batch 27 | Database.executeBatch(new mc_SubscriptionSyncBatch('Contact')); 28 | 29 | // Not asserting any results - the Marketing Cloud package will do it's thing 30 | } 31 | 32 | 33 | /** 34 | * @author Ben Edwards (ben@edwards.nz) 35 | * @description Test scenario of processing batch for Leads 36 | **/ 37 | @isTest 38 | static void testLeadBatchProcessingUnsubscribe () { 39 | 40 | // Create a new Lead 41 | Lead testLead = mc_TestData.createLead(); 42 | 43 | // Set the Lead to sync 44 | testLead.mc_Sync_to_Marketing_Cloud__c = true; 45 | 46 | // Set Opt Out to test both cases 47 | testLead.HasOptedOutOfEmail = true; 48 | update testLead; 49 | 50 | // Process the batch 51 | Database.executeBatch(new mc_SubscriptionSyncBatch('Lead')); 52 | 53 | // Not asserting any results - the Marketing Cloud package will do it's thing 54 | } 55 | 56 | 57 | /** 58 | * @author Ben Edwards (ben@edwards.nz) 59 | * @description Simple method to test scheduling the class 60 | **/ 61 | @isTest 62 | static void testSchedulable () { 63 | 64 | System.schedule('Test Hourly Schedule', '0 0 * * * ?', new mc_SubscriptionSyncBatch('Contact') ); 65 | } 66 | 67 | } -------------------------------------------------------------------------------- /src/classes/mc_TestData.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Generic utility class for generating data for test methods 5 | * 6 | * CHANGE LOG 7 | **/ 8 | @isTest 9 | public class mc_TestData { 10 | 11 | /** 12 | * @author Ben Edwards (ben@edwards.nz) 13 | * @description Instantiates and creates a dummy test Contact 14 | * @return Created Contact record 15 | **/ 16 | public static Contact createContact () { 17 | 18 | // Create a new Contact 19 | Contact testContact = new Contact(); 20 | testContact.FirstName = 'Snoop'; 21 | testContact.LastName = 'Dogg'; 22 | testContact.Email = 'snoop@dogg.com'; 23 | testContact.MobilePhone = '12 1234 7890'; 24 | testContact.MailingStreet = '1 Snoop Dogg Street'; 25 | testContact.MailingCity = 'Snooptown'; 26 | testContact.MailingState = 'California'; 27 | testContact.MailingCountry = 'United States'; 28 | testContact.MailingPostalCode = '12345'; 29 | 30 | // Insert the contact 31 | insert testContact; 32 | 33 | return testContact; 34 | } 35 | 36 | 37 | /** 38 | * @author Ben Edwards (ben@edwards.nz) 39 | * @description Instantiates and creates a dummy test Lead 40 | * @return Created Lead record 41 | **/ 42 | public static Lead createLead () { 43 | 44 | // Create a new Lead 45 | Lead testLead = new Lead(); 46 | testLead.FirstName = 'Snoop'; 47 | testLead.LastName = 'Dogg'; 48 | testLead.Company = 'Snoop Enterprises'; 49 | testLead.Email = 'snoop@dogg.com'; 50 | testLead.MobilePhone = '12 1234 7890'; 51 | testLead.Street = '1 Snoop Dogg Street'; 52 | testLead.City = 'Snooptown'; 53 | testLead.State = 'California'; 54 | testLead.Country = 'United States'; 55 | testLead.PostalCode = '12345'; 56 | 57 | // Insert the lead 58 | insert testLead; 59 | 60 | return testLead; 61 | } 62 | 63 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Salesforce to Marketing Cloud Subscription Sync 2 | ## Introduction 3 | By default, subscribing and unsubscribing a Lead/Contact in Salesforce and syncing to Marketing Cloud requires the clicking of a Unsubscribe or Resubscribe custom link. 4 | 5 | This process is a bit cumbersome, and also requires the Salesforce user to be synced to a Marketing Cloud user. 6 | 7 | This package provides code for automating changes of the Email Opt Out (HasOptedOutOfEmail) on Lead and Contact objects by subscribing/unsubscribing the subscriber in Marketing Cloud via API. It will attempt a real-time integration initially, but will also retry in 15 minute batches as a API user if the user who initiates the change isn't synced to a Marketing Cloud user. 8 | 9 | ## Installation 10 | Note: This package requires that the Marketing Cloud Connect package (v5+) is installed. More details here: 11 | https://help.marketingcloud.com/en/documentation/integrated_products__crm_and_web_analytic_solutions/marketing_cloud_connector_v5/ 12 | 13 | 14 | 1. **Install package to your Org** 15 | 16 | [![Deploy to Salesforce](https://andrewfawcett.files.wordpress.com/2014/09/deploy.png "Deploy to Salesforce")](https://githubsfdeploy.herokuapp.com/app/githubdeploy/benedwards44/sf-mc-subscription-sync) 17 | 18 | 19 | 2. **Enable sync options via Custom Setting** 20 | 21 | Setup -> Custom Settings -> Click Manage on Marketing Cloud Sync Settings -> New -> Check either (or both) Lead Sync and Contact Sync fields 22 | 23 | 24 | 3. **Schedule batch class** 25 | 26 | This is used to retry any errors and execute the sync as a user who is setup in Marketing Cloud. To schedule the batch, log in as a user that is set up as an API user in Marketing Cloud 27 | 28 | *Schedule Hourly (Contact)* 29 | 30 | `System.schedule('Marketing Cloud Sync - Contact', '0 0 * * * ?', new mc_SubscriptionSyncBatch('Lead'));` 31 | 32 | 33 | *Schedule Hourly (Lead)* 34 | 35 | `System.schedule('Marketing Cloud Sync - Lead', '0 0 * * * ?', new mc_SubscriptionSyncBatch('Contact'));` 36 | 37 | 38 | 39 | 4. **Add Marketing Cloud Sync Error custom fields to Lead and Contact layouts (OPTIONAL)** 40 | 41 | This allows users or administrators to check if there was a failure in syncing to Marketing Cloud. 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/classes/mc_SubscriptionSyncBatch.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ben Edwards (ben@edwards.nz) 3 | * @date 2016-05-31 4 | * @description Batch class to execute the physical callout of the update to Marketing Cloud 5 | * 6 | * CHANGE LOG 7 | **/ 8 | public class mc_SubscriptionSyncBatch implements Database.Batchable, Schedulable, Database.AllowsCallouts { 9 | 10 | // The object to process for the batch 11 | public final String objectType; 12 | 13 | // CONSTRUCTOR 14 | public mc_SubscriptionSyncBatch (String objType) { 15 | 16 | // Assign the object type from the constructor variable 17 | objectType = objType; 18 | } 19 | 20 | /* 21 | BATCH CLASS METHODS 22 | */ 23 | 24 | // The Start of the Batch 25 | public Database.QueryLocator start(Database.BatchableContext BC) { 26 | 27 | // Query all contacts that require to be synced 28 | return Database.getQueryLocator('SELECT Id, HasOptedOutOfEmail FROM ' + objectType + ' WHERE mc_Sync_to_Marketing_Cloud__c = true'); 29 | } 30 | 31 | // Execute the job logic 32 | public void execute(Database.BatchableContext BC, List scope) { 33 | 34 | // Iterate over contacts 35 | for (SObject recordTypeSync :scope) { 36 | 37 | // Execute the opt-out sync and capture result 38 | String result = executeUnSubReSubCallout ((Id) recordTypeSync.get('Id'), (Boolean) recordTypeSync.get('HasOptedOutOfEmail')); 39 | 40 | // If the contact is successfully synced 41 | if (result == 'success' || result == 'not found') { 42 | 43 | // Set sync flag to false, so it doesn't get picked up again 44 | recordTypeSync.put('mc_Sync_to_Marketing_Cloud__c', false); 45 | recordTypeSync.put('mc_Marketing_Cloud_Sync_Error__c', null); 46 | } 47 | else { 48 | 49 | // Set any errors against the contact 50 | recordTypeSync.put('mc_Marketing_Cloud_Sync_Error__c', result); 51 | } 52 | 53 | // If failed, leave the flag as true as it will get picked up in a scheduled batch and retried. 54 | } 55 | 56 | // Update the contacts so they aren't synced again 57 | update scope; 58 | } 59 | 60 | // Finish silently 61 | public void finish(Database.BatchableContext BC) {} 62 | 63 | /* 64 | SCHEDULABLE METHODS 65 | */ 66 | 67 | // The Schedulable execute method 68 | public void execute(SchedulableContext sc) { 69 | 70 | // Start the batch and run in seriers of 5. As this has to make a single callout per contact (not bulkified) 71 | // Only want to run in small batches 72 | Database.executeBatch(new mc_SubscriptionSyncBatch(objectType), 1); 73 | } 74 | 75 | 76 | 77 | /** 78 | * @author Ben Edwards (ben@cloudinit.nz) 79 | * @description Execute callout asyncronously to MC API 80 | **/ 81 | private String executeUnSubReSubCallout (Id recordId, Boolean isUnsub) { 82 | 83 | // Variable to capture the sync result. Set to failed. 84 | // The sync method will update to success if successful 85 | String syncResult = 'Not Started'; 86 | 87 | try { 88 | 89 | // Different methods for unsubscribe and re-subscribe 90 | if (isUnsub) { 91 | 92 | // Attempt unsub integration 93 | syncResult = et4ae5.jsButtonMethods.performUnsub(recordId, objectType); 94 | } 95 | else { 96 | 97 | // Attempt re-sub integration 98 | syncResult = et4ae5.jsButtonMethods.performResub(recordId, objectType); 99 | } 100 | } 101 | catch (Exception ex) { 102 | 103 | // Set the result to failed for any errors 104 | syncResult = String.valueOf(ex); 105 | 106 | // Capture failure in debug 107 | system.debug('### Error syncing Contact to Marketing Cloud: ' + ex); 108 | } 109 | 110 | return syncResult; 111 | } 112 | 113 | } --------------------------------------------------------------------------------