I promised another audience member who overheard to post an overview of our solution here - I'm sorry I haven't gotten to it until now!
This was made a while ago, meaning it's for Sitecore 8.2 and EXM 3.4 (update 2)
Creating a message type
First I needed to create a new message type. Since an SMS can only send text, I used Sitecore's TextMail as a base. First step is to make a new template in Sitecore, which has Sitecore's Plain Text Message as base template.
Having this template, I went ahead and created a message type in my project, including all the usual suspects. The "IsCorrectMessageItem" implementation uses the ID of my new template to identify the version.
public class SMS : TextMail { private readonly TextMailSource _curSource; protected SMS(Item item) : base(item) { this._curSource = (base.Source as TextMailSource); } public new static bool IsCorrectMessageItem(Item item) { return ItemUtilExt.IsTemplateDescendant(item, "{185B8860-3DFE-4B9E-A5DD-04A2D7DF3EC2}"); } public static SMS FromItemEx(Item item) { if (!SMS.IsCorrectMessageItem(item)) { return null; } return new SMS(item); } public override string GetMessageBody(bool preview) { return _curSource.Body; } public override object Clone() { SMS sms = new SMS(base.InnerItem); CloneFields(sms); return sms; } }
Then I need to override the TypeResolver to support the message type, which is quite simple. Just inhertit from the old TypeResolver, and start by checking whether the message is an SMS, otherwise fall back to normal behaviour:
public class TypeResolver : Sitecore.Modules.EmailCampaign.Core.TypeResolver { public override MessageItem GetCorrectMessageObject(Item item) { if (SMS.IsCorrectMessageItem(item)) { return SMS.FromItemEx(item); } return base.GetCorrectMessageObject(item); } }
And to enable this, we need to add a config include file like this
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <TypeResolver type="Sitecore.Modules.EmailCampaign.Core.TypeResolver,Sitecore.EmailCampaign" singleInstance="true"> <patch:attribute name="type">YourAssembly.EmailCampaign.TypeResolver, YourAssembly</patch:attribute> </TypeResolver> </sitecore> </configuration>
Sending the messages
Now that we've got a message type, we need to be able to send it. This is done by overriding the SendEmail processor in the SendEmail pipeline as below. You of course need to get some sort of SMS dispatch provider, below I've just assumed you can access an ISmsService from the IOC provider, which can send the SMS. On that part, you're on your own.
public class SendEmailSMSSupport : Sitecore.EmailCampaign.Cm.Pipelines.SendEmail.SendEmail { public SendEmailSMSSupport() : base() { } public void Process(SendMessageArgs args) { //If the message is a SMS, we send it here, otherwise we let base handle var sms = args.EcmMessage as SMS; if (sms == null) { base.Process(args); return; } //It's an SMS var message = args.CustomData["EmailMessage"] as EmailMessage; if (message.Recipients.Count < 1) { args.AddMessage("Missing Recipients from EmailMessage argument."); return; } EcmGlobals.GenerationSemaphore.ReleaseOne(); args.StartSendTime = DateTime.UtcNow; var from = message.Subject; var phoneNumber = sms.PersonalizationRecipient.GetProperties<Phone>()?.DefaultProperty?.PhoneNumber; var text = WebUtility.HtmlDecode(message.PlainTextBody); try { var service = ServiceLocator.ServiceProvider.GetRequiredService<ISmsService>(); service.SendSMS(from, phoneNumber, text); } catch (Exception ex) { throw new NonCriticalException(ex.Message, ex); } args.AddMessage("ok", PipelineMessageType.Information); args.SendingTime = Util.GetTimeDiff(args.StartSendTime, DateTime.UtcNow); } }
And configure it in an include file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <SendEmail> <processor type="Sitecore.EmailCampaign.Cm.Pipelines.SendEmail.SendEmail, Sitecore.EmailCampaign.Cm"> <patch:attribute name="type">YourAssembly.Pipelines.SendEmail.SendEmailSMSSupport, YourAssembly</patch:attribute> </processor> </SendEmail> </pipelines> </sitecore> </configuration>
Using the new message type
The above is technically enough to start sending SMS messages through EXM. Now you just need to log back into Sitecore, create a branch from your new Message Type template, and include that branch in the insert options of each of your Message Types in the EXM root (like you would any new message type). And you can start creating messages of this type and sending them.
Technically...?
Unfortunately Sitecore has added a couple of email specific checks two other places in the sending process - and seemingly gone out of their way to make it hard to override (by making very long methods, with heavy use of private and internal helper methods).
If you're certain all of your users all have a valid email, you should be able to skip the below sections.
However if that's not necesarily the case, you will need to implement something along these lines as well. Note this code is copy pasted from decompilation of Sitecore through ILSpy, and only the absolutely necesary changes are made. Usage of private and internal helper methods are mimicked through reflection.
Overriding DispatchTask
I needed to override the OnSendToNextRecipient method DispatchTask, in order to remove a check for valid email. It's replaced by a check that phone number is not empty.
/// <summary> /// Override of DispatchTask in order to avoid that dispatching of SMS is dropped if there's no valid email on the customer (instead we check that phone is filled in). /// It's fairly ugly, as it uses so many internal and private fields. All the private fields in this class is just reflection in order for this method to succeed. /// A bit of the statistics and email specific things has been removed from this class, as it doesn't really make sense in an SMS context anyhow. /// The config to use this is as such: /// </summary> public class DispatchTask : Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask { protected override ProgressFeedback OnSendToNextRecipient() { if (!(base.Message is SMS)) { return base.OnSendToNextRecipient(); } if (base.InterruptRequest != null) { switch (base.InterruptRequest()) { case DispatchInterruptSignal.Abort: return ProgressFeedback.Abort; case DispatchInterruptSignal.Pause: return ProgressFeedback.Pause; } } RecipientId recipientId = null; DateTime utcNow = DateTime.UtcNow; EcmGlobals.GenerationSemaphore.WaitOne(); SendMessageArgs sendMessageArgs = null; try { Context.Site = Util.GetContentSite(); using (new SecurityDisabler()) { DispatchQueueItem dispatchQueueItem; Exception ex; if (!_recipientQueueTryGetNextItem(out dispatchQueueItem, out ex)) { ProgressFeedback result = ProgressFeedback.Finish; return result; } if (ex != null) { this.Logger.LogError(string.Format("Failed to retrieve recipient for the message '{0}' from the dispatch queue.", base.Message.InnerItem.Name), ex); ProgressFeedback result = ProgressFeedback.Pause; return result; } recipientId = _recipientRepository.ResolveRecipientId(dispatchQueueItem.RecipientId); Recipient recipient = null; if (recipientId != null) { try { recipient = _recipientRepository.GetRecipient(recipientId); } catch (Exception e) { this.Logger.LogError("Failed to retrieve recipient.", e); throw new NonCriticalException(string.Format("Recipient {0} skipped. Failed to retrieve the recipient from its repository.", recipientId), new object[0]); } } if (recipient == null) { throw new NonCriticalException("The recipient '{0}' does not exist.", new object[] { recipientId }); } Guid contactId = dispatchQueueItem.ContactId; Phone defaultProperty = recipient.GetProperties<Phone>().DefaultProperty; string email = "sms"; bool flag = defaultProperty != null && !string.IsNullOrWhiteSpace(defaultProperty.PhoneNumber); bool flag2 = !base.CheckContactSubscription || !GlobalSettings.Instance.GetCheckContactSubscriptionAfterDispatchPause() || (base.Message.MessageType != MessageType.OneTime && base.Message.MessageType != MessageType.Subscription) || base.Message.IsSubscribed(recipientId).Value; Language language = _decidePreferredLanguage(recipient); ExmCustomValues exmCustomValues = new ExmCustomValues { DispatchType = dispatchQueueItem.DispatchType, Email = email, MessageLanguage = language.ToString(), ManagerRootId = base.Message.ManagerRoot.InnerItem.ID.ToGuid(), MessageId = base.Message.InnerItem.ID.ToGuid() }; if (!flag2) { base.Factory.Gateways.EcmDataGateway.SetMessageStatisticData(this._campaign.ID.ToGuid(), null, null, FieldUpdate.Add<int>(-1), null, null, null); if (!flag2) { this.Logger.LogInfo(string.Format("\r\n Recipient {0} skipped due to the recipient has been added to OptOut list during the sending process.", recipientId)); } else { this.Logger.LogInfo(string.Format("\r\n Recipient {0} skipped because the recipient is in the suppression list.", recipientId)); } } else if (!flag) { base.Factory.Bl.DispatchManager.EnrollOrUpdateContact(contactId, dispatchQueueItem, base.Message.PlanId.ToGuid(), "Invalid Address", exmCustomValues); this.Logger.LogWarn(string.Format("Message '{0}': Recipient is skipped. ", base.Message.InnerItem.Name) + string.Format("'{0}' is not a valid email address.", email)); this.Logger.LogInfo(string.Format("Recipient {0} do not have a phonenumber", recipient.RecipientId)); } else { MessageItem messageItem = _generateEmailContent(recipient, email, language, contactId, dispatchQueueItem.CustomPersonTokens); if (base.Factory.Bl.TestLabHelper.IsTestConfigured(base.Message)) { exmCustomValues.TestValueIndex = base.Factory.Bl.TestLabHelper.GetMessageTestValueIndex(messageItem); } EnrollOrUpdateContactResult enrollOrUpdateContactResult; try { enrollOrUpdateContactResult = base.Factory.Bl.DispatchManager.EnrollOrUpdateContact(contactId, dispatchQueueItem, base.Message.PlanId.ToGuid(), "Send Completed", exmCustomValues); } catch (Exception e2) { this.Logger.LogError("Failed to enroll a contact in the engagement plan.", e2); throw new NonCriticalException(string.Format("Recipient {0} skipped. Failed to enroll its corresponding contact in the engagement plan.", recipientId), new object[0]); } if (enrollOrUpdateContactResult == EnrollOrUpdateContactResult.Failed) { this.Logger.LogInfo(string.Format("Recipient {0} could not be enrolled in the engagement plan for message {1}.", recipientId, base.Message.InnerItem.Name)); } if (base.Message.MessageType == MessageType.Triggered && enrollOrUpdateContactResult == EnrollOrUpdateContactResult.ContactEnrolled) { base.Factory.Gateways.EcmDataGateway.SetMessageStatisticData(this._campaign.ID.ToGuid(), null, null, FieldUpdate.Add<int>(1), null, null, null); } sendMessageArgs = new SendMessageArgs(messageItem, this); _pipelineHelper.RunPipeline("SendEmail", sendMessageArgs); if (sendMessageArgs.Aborted) { this.Logger.LogInfo(string.Format("The '{0}' pipeline is aborted.", "SendEmail")); throw new NonCriticalException("Message not sent to the following recipient: {0}.", new object[] { recipientId }); } } base.Factory.Gateways.EcmDataGateway.DeleteRecipientsFromDispatchQueue(dispatchQueueItem.Id); } } catch (NonCriticalException e3) { this.Logger.LogError(e3); if (recipientId != null) { this.Logger.LogError(string.Format("Failed to send '{0}' to '{1}'.", base.Message.InnerItem.Name, recipientId)); } } catch (SmtpException arg) { this.Logger.LogError("Message sending error: " + arg); ProgressFeedback result = ProgressFeedback.Pause; return result; } catch (AggregateException ex2) { this.Logger.LogError("Message sending error: " + ex2); if (recipientId != null) { this.Logger.LogError(string.Format("Failed to send '{0}' to '{1}'.", base.Message.InnerItem.Name, recipientId)); } bool pause = true; ex2.Flatten().Handle(delegate (Exception x) { if (x is InvalidMessageException) { pause = false; } return true; }); if (pause) { ProgressFeedback result = ProgressFeedback.Pause; return result; } } catch (Exception arg2) { if (recipientId != null) { this.Logger.LogError(string.Format("Failed to send '{0}' to '{1}'.", base.Message.InnerItem.Name, recipientId)); } this.Logger.LogError("Message sending error: " + arg2); ProgressFeedback result = ProgressFeedback.Pause; return result; } finally { if (sendMessageArgs == null || !(sendMessageArgs.StartSendTime > DateTime.MinValue)) { EcmGlobals.GenerationSemaphore.ReleaseOne(); } } return ProgressFeedback.Continue; } private bool _recipientQueueTryGetNextItem(out DispatchQueueItem dispatchQueueItem, out Exception ex) { var queuefield = typeof(Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask).GetField("_recipientQueue", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var queue = queuefield.GetValue(this); var method = queue.GetType().GetMethod("TryGetNextItem", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var parameters = new object[] { null, null }; var result = method.Invoke(queue, parameters); dispatchQueueItem = parameters[0] as DispatchQueueItem; ex = parameters[1] as Exception; return (bool)result; } private MessageItem _generateEmailContent(Recipient recipient, string emailAddress, Language language, Guid recipientId, Dictionary<string, object> customPersonTokens) { var method = typeof(Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask).GetMethod("GenerateEmailContent", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var parameters = new object[] { recipient, emailAddress, language, recipientId, customPersonTokens, null, null, null, null }; var result = method.Invoke(this, parameters); return (MessageItem)result; } private Language _decidePreferredLanguage(Recipient recipient) { var method = typeof(Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask).GetMethod("DecidePreferredLanguage", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var parameters = new object[] { recipient }; var result = method.Invoke(this, parameters); return (Language)result; } private RecipientRepository _recipientRepository { get { var fieldInfo = typeof(Sitecore.Modules.EmailCampaign.Factories.BusinessLogicFactory).GetField("_recipientRepository", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var val = fieldInfo.GetValue(base.Factory.Bl); var initOnceType = val.GetType(); var prop = initOnceType.GetProperty("Value", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); return prop.GetValue(val) as RecipientRepository; } } private PipelineHelper _pipelineHelper { get { var fieldInfo = typeof(Sitecore.Modules.EmailCampaign.Factories.BusinessLogicFactory).GetField("_pipelineHelper", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); var val = fieldInfo.GetValue(base.Factory.Bl); var initOnceType = val.GetType(); var prop = initOnceType.GetProperty("Value", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); return prop.GetValue(val) as PipelineHelper; } } private Item _campaign { get { var fieldInfo = typeof(Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask).GetField("_campaign", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); return fieldInfo.GetValue(this) as Item; } } }
and the configuration...
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <DispatchTask type="Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask, Sitecore.EmailCampaign"> <patch:attribute name="type">YourAssembly.EmailCampaign.DispatchTask, YourAssembly</patch:attribute> </DispatchTask> </sitecore> </configuration>
Overriding DispatchManager:
DispatchManager also has a ValidateRecipientForEmailDispatchOrLogDetails method which checks for valid email and maxUndeliveredCount and stops the sending if they're not set properly. Now this one is basically twice as annoying as the previous one. Because there's an infinite loop in the instantiation when trying to override the class - the public parameterless constructor calls an internal constructor with a
EcmFactory.GetDefaultFactory() argument, however this tries to instantiate yet another DispatchManager - causing the StackOverflow.
So, in order to fix this, we need to make a Wrapper of the class first, with a lazy instantiation of the override we do, so it's never called until the entire Factory has been instantiated once.
The wrapper class just looks like this:
/// <summary> /// Hacky method of overriding the DispatchManager - Sitecore's DispatchManager does not provide a public constructor that does not result in an infinite loop. /// We needed to override, so we made an override, and use this wrapper to call it without getting errors. /// The normal constructor makes sure that a private EcmFactory is set on the base DispatchManager, we need to make sure to set this ourselves before calling anything public class DispatchManagerWrapper : IDispatchManager { private Lazy<DispatchManagerOverride> _managerOverride = new Lazy<DispatchManagerOverride>(() => return new DispatchManagerOverride()); public void AddRecipientsToDispatchQueue(DispatchNewsletterArgs args, List<RecipientId> recipientIds, int enqueueBatchSize) { _managerOverride.Value.AddRecipientsToDispatchQueue(args, recipientIds, enqueueBatchSize); } public void AddRecipientToDispatchQueue(DispatchNewsletterArgs args, RecipientId recipientId) { _managerOverride.Value.AddRecipientToDispatchQueue(args, recipientId); } public EnrollOrUpdateContactResult EnrollOrUpdateContact(Guid contactId, DispatchQueueItem dispatchQueueItem, Guid planId, string stateName, ExmCustomValues customValues) { return _managerOverride.Value.EnrollOrUpdateContact(contactId, dispatchQueueItem, planId, stateName, customValues); } public bool InterruptDispatch(DispatchInterruptSignal signal, Guid messageId, bool forwardToDedicatedServers) { return _managerOverride.Value.InterruptDispatch(signal, messageId, forwardToDedicatedServers); } public bool IsRecipientInContactLists(HashSet<ID> contactListIds, Recipient recipient) { return _managerOverride.Value.IsRecipientInContactLists(contactListIds, recipient); } public void RebalanceAbTestGroup(ID messageId, int numTotalRecipients, int numAbTestRecipients, float preferredAbTestPercentage) { _managerOverride.Value.RebalanceAbTestGroup(messageId, numTotalRecipients, numAbTestRecipients, preferredAbTestPercentage); } public IEnumerable<Guid> ResetFailedTriggered(TimeSpan timeout) { return _managerOverride.Value.ResetFailedTriggered(timeout); } public void ResetFailedTriggered(Guid messageId, TimeSpan timeout) { _managerOverride.Value.ResetFailedTriggered(messageId, timeout); } public void ResetRecipientsToNotInProgress(Guid messageId) { _managerOverride.Value.ResetRecipientsToNotInProgress(messageId); } public void RestoreCustomTokensFromQueue(MessageItem message, Dictionary<string, object> customPersonTokens) { _managerOverride.Value.RestoreCustomTokensFromQueue(message, customPersonTokens); } public bool ValidateRecipientForEmailDispatchOrLogDetails(Recipient recipient, RecipientId recipientId, string messageName, int maxUndeliveredCount, out Guid contactId) { return _managerOverride.Value.ValidateRecipientForEmailDispatchOrLogDetails(recipient, recipientId, messageName, maxUndeliveredCount, out contactId); } }
/// <summary> /// Overrides the DispatchManager to make sure that dispatching of SMS does not get cancelled if bouncecount is high or if the user has an invalid email. /// We use a Dictionary to remember whether messages of specific names are SMS'es. We never remove from this list as it could provide problems with threaded dispatches, but the amount of data is so small that it doesn't matter if it lives with the application. /// In theory this could fail, if a mail and and SMS of the same name is dispatched at the same time. But the odds of that are very low /// </summary> public class DispatchManagerOverride : DispatchManager { private Dictionary<string, bool> _messageIsSms = new Dictionary<string, bool>(); private FieldInfo _baseManagerFactory = typeof(DispatchManager).GetField("_factory", BindingFlags.NonPublic | BindingFlags.Instance); private void PersistMessageType(MessageItem message) { var name = message.InnerItem.Name; //as used in IsValidRecipient To call ValidateRecipientForEmailDispatchOrLogDetails if (_messageIsSms.ContainsKey(name)) { _messageIsSms[name] = message is SMS; } else { _messageIsSms.Add(name, message is SMS); } } public override void AddRecipientsToDispatchQueue(DispatchNewsletterArgs args, List<RecipientId> recipientIds, int enqueueBatchSize) { PersistMessageType(args.Message); base.AddRecipientsToDispatchQueue(args, recipientIds, enqueueBatchSize); } public override bool ValidateRecipientForEmailDispatchOrLogDetails(Recipient recipient, RecipientId recipientId, string messageName, int maxUndeliveredCount, out Guid contactId) { //We take factory from base, and check if the message is an SMS - if it's an SMS we dont check for valid email and maxUndeliveredCount. The rest is copied from Sitecore DLL var factory = _baseManagerFactory.GetValue(this) as EcmFactory; var isSms = _messageIsSms.ContainsKey(messageName) && _messageIsSms[messageName]; if (recipient == null) { factory.Io.Logger.LogDebug("Message '{0}': Recipient is skipped. ".FormatWith(new object[] { messageName }) + "The recipient '{0}' does not exist.".FormatWith(new object[] { recipientId })); contactId = Guid.Empty; return false; } XdbRelation defaultProperty = recipient.GetProperties<XdbRelation>().DefaultProperty; if (defaultProperty == null) { factory.Io.Logger.LogDebug("Message '{0}': Recipient is skipped. ".FormatWith(new object[] { messageName }) + "The recipient '{0}' has no relation to a contact in XDB.".FormatWith(new object[] { recipientId })); contactId = Guid.Empty; return false; } if (string.IsNullOrEmpty(defaultProperty.Identifier)) { factory.Io.Logger.LogDebug("Message '{0}': Recipient is skipped. ".FormatWith(new object[] { messageName }) + "The recipient '{0}' has no identifier in XDB.".FormatWith(new object[] { recipientId })); contactId = Guid.Empty; return false; } contactId = defaultProperty.ContactId.Guid; CommunicationSettings defaultProperty2 = recipient.GetProperties<CommunicationSettings>().DefaultProperty; if (defaultProperty2 != null && defaultProperty2.IsCommunicationSuspended) { factory.Io.Logger.LogDebug("Message '{0}': Recipient is skipped. ".FormatWith(new object[] { messageName }) + "The user '{0}' is disabled.".FormatWith(new object[] { recipientId })); return false; } if (!isSms) { Email defaultProperty3 = recipient.GetProperties<Email>().DefaultProperty; if (defaultProperty3 == null) { factory.Io.Logger.LogDebug("Message '{0}': Recipient is skipped. ".FormatWith(new object[] { messageName }) + "No email address was associated with recipient '{0}'.".FormatWith(new object[] { recipientId })); return false; } if (defaultProperty3.UndeliveredCount.HasValue && maxUndeliveredCount > 0 && defaultProperty3.UndeliveredCount.Value >= maxUndeliveredCount) { factory.Io.Logger.LogDebug("Message '{0}': Recipient is skipped. ".FormatWith(new object[] { messageName }) + "The maximum number of failed deliveries has been reached for '{0}'.".FormatWith(new object[] { recipientId })); return false; } } return true; } }
And one last configuration...
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <DispatchTask type="Sitecore.Modules.EmailCampaign.Core.Dispatch.DispatchTask, Sitecore.EmailCampaign"> <patch:attribute name="type">YourAssembly.EmailCampaign.DispatchTask, YourAssembly</patch:attribute> </DispatchTask> </sitecore> </configuration>